From a8ed935f6a2cca0a99835063bb3a7925027cf7ab Mon Sep 17 00:00:00 2001 From: Catty Steve <4795515+Catty2014@user.noreply.gitee.com> Date: Thu, 16 Apr 2026 23:33:03 +0800 Subject: [PATCH] feat(baker/finalize): Add finalize stage framework with plugin system and template Implements the finalize stage infrastructure for HoneyBiscuitWorkshop: - New finalize module with config, error, event, plugin, stage, and template submodules - Plugin system supporting internal (webhook, mail, satori, in-site) and external plugins - FinalizeConfig schema with version validation for finalize.yml - Add glob, duration-str, lettre dependencies to workshop-baker - Expand project documentation (clarification.md, description.md) - Update .gitignore and fix bake.rs pipeline_functions access BREAKING CHANGE: Not a buildable version! --- .gitignore | 4 + clarification.md | 1211 +++++++++++++++++ description.md | 62 + description.txt | 29 - finalize.yml.tmpl | 366 +++++ prebake.yml.tmpl | 3 + workshop-baker/Cargo.lock | 382 +++++- workshop-baker/Cargo.toml | 4 + workshop-baker/src/bake.rs | 25 +- workshop-baker/src/bake/parser.rs | 555 ++++---- workshop-baker/src/constant.rs | 1 + workshop-baker/src/finalize.rs | 49 + workshop-baker/src/finalize/config.rs | 269 ++++ workshop-baker/src/finalize/error.rs | 64 + workshop-baker/src/finalize/event.rs | 36 + workshop-baker/src/finalize/plugin.rs | 113 ++ .../src/finalize/plugin/internal.rs | 31 + .../src/finalize/plugin/internal/dummy.rs | 18 + .../finalize/plugin/internal/insitenotify.rs | 17 + .../src/finalize/plugin/internal/mail.rs | 18 + .../src/finalize/plugin/internal/satori.rs | 18 + .../src/finalize/plugin/internal/webhook.rs | 18 + workshop-baker/src/finalize/stage.rs | 10 + workshop-baker/src/finalize/template.rs | 11 + workshop-baker/src/lib.rs | 1 + 25 files changed, 2980 insertions(+), 335 deletions(-) create mode 100644 clarification.md create mode 100644 description.md delete mode 100644 description.txt create mode 100644 finalize.yml.tmpl create mode 100644 workshop-baker/src/finalize/config.rs create mode 100644 workshop-baker/src/finalize/error.rs create mode 100644 workshop-baker/src/finalize/event.rs create mode 100644 workshop-baker/src/finalize/plugin.rs create mode 100644 workshop-baker/src/finalize/plugin/internal.rs create mode 100644 workshop-baker/src/finalize/plugin/internal/dummy.rs create mode 100644 workshop-baker/src/finalize/plugin/internal/insitenotify.rs create mode 100644 workshop-baker/src/finalize/plugin/internal/mail.rs create mode 100644 workshop-baker/src/finalize/plugin/internal/satori.rs create mode 100644 workshop-baker/src/finalize/plugin/internal/webhook.rs create mode 100644 workshop-baker/src/finalize/stage.rs create mode 100644 workshop-baker/src/finalize/template.rs diff --git a/.gitignore b/.gitignore index 8da57dc..6b2d7e4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ playground .codeartsdoer archived +.sisyphus +*.bak +*.clean +session* diff --git a/clarification.md b/clarification.md new file mode 100644 index 0000000..1e23b89 --- /dev/null +++ b/clarification.md @@ -0,0 +1,1211 @@ + +## Updated Questions & Doubts + +### A. Plugin System + +1. **`fallible` vs `critical`** + - You changed from `critical: false` to `fallible: false` + - Is `fallible: true` the same as `critical: false`? (Double negative situation) + - Consider: `fail_build_on_error: true/false` is clearer + +2. **Plugin URI Format** + - `git::github.com/user/repo@main/1234abcd` + - Is `main` the branch and `1234abcd` the commit? + - What if branch and commit don't match? Which takes precedence? + - Are other protocols supported? (`hg::`, `http::`, `file::`?) + +3. **Plugin Configuration Scope** + - In `plugin:` section, you define `host`, `user`, `token` + - In `publish:`, you define `image: "docker.io/user/image:tag"` + - Are these merged? Overridden? + - What if `publish.docker-push.image` differs from `plugin.docker-push.host`? + +4. **Multiple Plugin Implementations** + - Can `docker-push:` have multiple URIs under it? + ```yaml + docker-push: + "git::github.com/user/plugin-a@main": ... + "git::github.com/user/plugin-b@main": ... # Allowed? + ``` + +### B. Notification Priority + +5. **Fallback Semantics** + - Comment: "inter-group falling back" + - Does group `1` run **only if** group `0` fails? + - Or does group `1` run **after** group `0` succeeds? + - Or both groups run, but `1` is lower priority for delivery? + +6. **Parallel Within Group** + - If `webhook` succeeds but `satori` fails in group `0`, what happens? + - Does group `1` get triggered because of partial failure? + - Or is group `0` considered "succeeded" if at least one succeeds? + +7. **Notification Failure vs Build Failure** + - If all notifications fail, does the build fail? + - Is there a global `fallible` for notifications? + +### C. Stage-Based Notifications + +8. **`on_finish_of` Integration** + ```yaml + on_finish_of: + stage: "prebake.ready" + ``` + - How does this integrate with `PrebakeStage` enum from codebase? + - Current stages: `Bootstrap -> Ready` (from AGENTS.md) + - Are all stages valid? (`prebake.bootstrap`, `bake.complete`, `finalize.*`?) + +9. **`on_finish_of` vs `on_success`/`on_failure`** + - Can these coexist on the same notification? + - Does `on_finish_of` trigger regardless of success/failure? + - Is there `on_start_of` for "notify when stage begins"? + +10. **Stage Timing** + - When `on_finish_of: prebake.ready` fires, is `{{ build.status }}` available? + - Or is build status only meaningful after `bake` stage? + +### D. Artifact System + +11. **Auto-Generated ID** + ```yaml + id: "" # Generated by Pipeline UUID & Path when not given + ``` + - Is `id` optional now? Can you omit it entirely? + - If auto-generated, how do you reference it in `publish`? + - Or is `publish` always by `path` match, not `id`? + +12. **Empty Path Semantics** + ```yaml + path: "" # Implies non-directory artifact + ``` + - What is a "non-directory artifact"? + - Is this for metadata-only artifacts? + - Or does empty path mean "use default output path from bake.sh"? + +13. **Retention Duration Parsing** + - `"7d"`, `604800` (seconds?), `"168h"` - are these equivalent? + - What's the canonical format? Should there be validation? + - Consider standardizing: ISO 8601 duration (`P7D`)? + +14. **Compression `dir` Option** + ```yaml + compression: "zstd" # gzip|none|dir allowed + ``` + - What does `compression: "dir"` mean? No compression, keep as directory? + - Should this be `"directory"` or `"raw"` for clarity? + +### E. Template Variables + +15. **Variable Availability by Stage** + - `{{ build.duration }}` - available at `prebake.ready`? (Probably not) + - `{{ build.status }}` - what values? `"success"` | `"failure"` | `"running"`? + - Should there be stage-specific variable namespaces? (`{{ prebake.* }}`, `{{ bake.* }}`) + +16. **Target Namespace** + - `{{ target.group_id }}` - where is this defined? + - Is `target` the notification target, or build target environment? + - Can users define custom `{{ target.* }}` variables? + +### F. Schema & Validation + +17. **Schema Field Purpose** + ```yaml + schema: "default" + schema: "custom" + ``` + - What does `default` schema do? Built-in payload format? + - Is `custom` required when `on_*` templates are provided? + - Can you use `schema: "slack"` or other named schemas? + +18. **Version Compatibility** + - `version: "0.0.1"` - suggests breaking changes expected + - How will version migrations work? + - Should there be a `schema_version` separate from implementation version? + +### G. Missing Features (Intentional?) + +19. **Conditional Artifacts** + - Can artifacts have conditions like bake.sh's `@if()`? + - E.g., only publish docker image on `main` branch + +20. **Artifact Dependencies** + - Can one artifact depend on another? + - E.g., "publish only if artifact A was successfully uploaded" + +21. **Cleanup Granularity** + ```yaml + cleanup: + policy: "auto" + ``` + - What other policies exist? `"manual"`? `"on_failure"`? + - Is cleanup per-artifact or global? + +22. **Timeouts & Retries for Notifications/Plugins** + - No timeout/retry config visible + - Are these inherited from bake.sh decorators? + - Should plugins have their own retry logic? + +--- + +### Summary of Design Gaps + +| Area | Missing/Unclear | +|------|-----------------| +| Plugin | Config merging, multiple URIs, protocol support | +| Notification | Fallback semantics, failure propagation, stage integration | +| Artifact | Auto-ID referencing, empty path use case, conditions | +| Template | Variable availability by stage, custom namespaces | +| Schema | `default`/`custom` behavior, extensibility | +| Error Handling | Timeouts, retries, failure escalation | + +What would you like to clarify or iterate on? + +--- + +## User + +1. fallible:true = critical: false, of course. This concept is the same as bake.sh +2. if no according commit/tag in the branch, it means nothing and fail. +Other protocols will be supported but some are sonner(file) and some are later(hg) +3. Caller than Definition: parameters in publish overrides plugin +No way could user success if image.host != host. host is only for authentication usage. image.host could be emitted if host given. +4. not allowed. key is unique. +5/6/7. fallback explanation: (notification->no) +no.0 starts all at once +if one method in no.0 fails, no.1 is active (then no.2,...) +the failure of no.0 will be part of no.1, this means baker will wait for no.0 to finish and then no.1 +fallible could apply here +8. for prebake/finalize, second field is static, for bake, second field is dynamic according to bake.sh / Function(class) +validity checks upon execution, maybe early-checked at future +9. yes +maybe a condition system required +10. Still WIP +11. ID is optional and will be generated by system +publish doesn't reference artifact, artifact reference publish +12. Docker image as an example +future url:// based path, such as docker://docker.io/user/image:tag +adopted from RedoxOS, however too advanced to promote. +13. equivalent. validation required. default unit is second +14. dir will be used for software repo: they need structued directory than single .tar +15. WIP +16. WIP +17. default will be defined by (internal) plugins +custom required for on* +schema could be defined in notification block for unified use +18. We'd expect not to change schema after 1.0, though it's impossible +migration system built-in for new version +19. yes +20. yes +21. WIP +22. WIP + +--- + +## Assistant (Sisyphus (Ultraworker) · GLM-5 · 57.4s) + +## Synthesized Design Understanding + +Based on your clarifications, here's the refined design: + +### Plugin System +```yaml +plugin: + : # Unique key + "::@/": + # Auth/config parameters (can be overridden in publish) + host: "docker.io" + user: {{ dockerhub.user }} + token: {{ secret.dockerhub.token }} + fallible: false # Same semantics as bake.sh @fallible +``` +- `host` = authentication endpoint only +- `publish..image` can omit host if plugin `host` defined + +### Notification Flow +``` +Priority 0: [webhook, satori] ──parallel──┬──► if ALL fail ──► Priority 1: [mail] + │ + └──► wait for completion before next group +``` +- Within group: parallel +- Across groups: sequential fallback on failure +- `fallible` can apply per notification method + +### Artifact Publishing +```yaml +artifact: + - path: "" # Empty = URL-style artifact (docker://, etc.) + publish: + : + # Override plugin params here + image: "docker.io/user/image:tag" # host optional if plugin defines it +``` +- Artifact **pushes to** publish, not publish pulls artifact +- ID auto-generated when omitted + +--- + +## Follow-Up Questions + +### A. Plugin Config Merging + +1. **Deep vs Shallow Merge** + ```yaml + plugin: + docker-push: + host: "docker.io" + auth: + user: "admin" + token: "xxx" + + publish: + docker-push: + auth: + user: "override-user" # Does this merge {user, token} or replace entire auth? + ``` + - Is merge **deep** (nested objects merge) or **shallow** (replace entire key)? + +2. **Required vs Optional Plugin Params** + - If plugin defines `host` and `user` as required, but publish omits `user`... + - Is there a schema for plugin to declare required params? + - Or is validation purely "fail at runtime if missing"? + +### B. Notification Fallback Edge Cases + +3. **Partial Failure Within Group** + ``` + Priority 0: webhook ✓, satori ✗ + ``` + - Is group 0 considered "failed" if **any** method fails? + - Or only if **all** methods fail? + - This determines whether priority 1 triggers + +4. **Timeout Interaction with Fallback** + ```yaml + 0: + webhook: + url: "..." + timeout: 30s # Hypothetical + 1: + mail: ... + ``` + - If webhook times out at 30s, does mail immediately start? + - Or does system wait for webhook's internal retry logic first? + +5. **Fallback with `fallible`** + ```yaml + 0: + webhook: + fallible: true + 1: + mail: + fallible: false + ``` + - If webhook fails but is `fallible: true`, does mail still trigger? + - Or does `fallible: true` mean "failure doesn't trigger fallback"? + +### C. Stage-Based Notifications + +6. **Stage Naming Convention** + - `prebake.ready` - static name + - `bake.build` - function name from bake.sh? + - What about `finalize` stages? `finalize.artifact`? `finalize.notification`? + - Are there built-in stages that always exist? + +7. **Multiple `on_finish_of` Triggers** + ```yaml + satori: + on_finish_of: + - stage: "prebake.ready" + - stage: "bake.test" + ``` + - Can a notification trigger at multiple stages? + - Or is it one `on_finish_of` per notification definition? + +8. **`on_success` + `on_finish_of` Combination** + ```yaml + satori: + on_finish_of: + stage: "prebake.ready" + on_success: | # Build success, not stage success? + ``` + - If `on_finish_of: prebake.ready` fires but build eventually fails... + - Does `on_success` content get used or `on_failure`? + - Or does `on_finish_of` have its own `content` only? + +### D. Artifact Path Schemes + +9. **URL Scheme Examples** + ```yaml + path: "docker://docker.io/user/image:tag" + path: "s3://bucket/key" + path: "ghcr://user/repo:tag" + ``` + - Are these the intended schemes? Or something else? + - Does each scheme map to a specific plugin automatically? + +10. **Mixed Path Types in Single Config** + ```yaml + artifact: + - path: "/workspace/output.tar.gz" # File + - path: "" # Docker image (how to specify image name?) + - path: "docker://docker.io/img:tag" # Explicit scheme + ``` + - When `path: ""`, where does the image name come from? Only from `publish.docker-push.image`? + - Can you have `path: ""` without `publish`? (Just store locally?) + +### E. Compression & Directory Artifacts + +11. **`dir` Compression + Upload** + ```yaml + artifact: + - path: "/workspace/output/" + compression: "dir" + publish: + some-plugin: ... + ``` + - When uploading a `dir` artifact, does plugin receive: + - The directory path? + - A tarball created on-the-fly? + - Individual files? + - How does `docker-push` handle `compression: "dir"`? + +### F. Conditional System (Planned) + +12. **Condition Syntax** + ```yaml + artifact: + - path: "..." + publish: + docker-push: + image: "..." + if: "${BUILD_BRANCH} == 'main'" # Hypothetical + ``` + - Will conditions use shell-style `$VAR`? + - Or template-style `{{ env.BRANCH }}`? + - Or bake.sh-style `@if(WS_PIPELINE_ARCH == amd64)`? + +### G. Error Handling + +13. **Plugin Execution Failure** + ```yaml + artifact: + - publish: + docker-push: + fallible: true + another-plugin: + fallible: false + ``` + - If `docker-push` fails (fallible), does `another-plugin` still run? + - Or do all plugins for an artifact run in parallel regardless? + +14. **Artifact Collection Failure** + - What if `path: "/workspace/target/release/*"` matches nothing? + - Is this a build failure or silently skipped? + - Is there `on: ["missing"]` for handling missing artifacts? + +--- + +## Design Consistency Observations + +### Alignment with Existing Patterns + +| Concept | prebake.yml | bake.sh | finalize.yml | +|---------|-------------|---------|--------------| +| Fallible | - | `@fallible` | `fallible: bool` ✓ | +| Timeout | - | `@timeout(30)` | WIP | +| Retry | - | `@retry(3, 5)` | WIP | +| Parallel | - | `@parallel` | Intra-group parallel ✓ | +| Conditional | - | `@if(cond)` | Planned ✓ | +| After/Deps | `hooks.early/late` | `@after(func)` | Priority groups ✓ | + +### Potential Inconsistencies + +1. **`retension` typo** - Should be `retention` + +2. **Duration format inconsistency** + - prebake.yml: `ttl_days: 30` + - finalize.yml: `retension: "7d"` + - Consider standardizing to one format + +3. **Plugin reference style** + - bake.sh: `@after(func_name)` - direct reference + - finalize.yml: `publish: docker-push:` - nested config + - Both valid, just noting the difference + +--- + +What would you like to explore next? + +--- + +## User + +3. any +1. overrides "auth" than "user" +2. checker cries. checked implemented in plugin +4. same as 3. timeout = single failure, not group failure +5. fallible of a stage will stop error propagation, thus leaving 1 alone. +for last stage, fallible means no other method of notification. +(if none of notification FULLY works, system-level fallback, like user email via system(SHOULD be well-configured because user creation relies on this) and in-site notification) +6. WIP cuz finalize itself is our plan +7. yes, logical expression may be introduced +8. every condition have its own content +9. not quite planned by now. +not completely correct? bake.sh shoudn't upload content to S3 and let finalize to fetch it for artifact +however maybe we don't prohibit this. +users may do everything in bake and skip finalize. +10. path:none + publish: none means nothing, because the build environment will soon be destroyed +if it's saved externally (eg. via docker socket), then it shoudn't exist as artifact +11. plugin receive a prefetched directory at plugin workspace, this is ensured by rsync. +docker-push should ignore compression (or maybe prohibit it) +12. WIP +13. if one fails but fallible, others will run as expected +14. matches nothing invalidates artifact content, but plugin may handle "none" as content +15. retention IS typo. +16. we haven't implemented prebake.cache thus they might be unified + +--- + +## Assistant (Sisyphus (Ultraworker) · GLM-5 · 54.4s) + +## Updated Design Understanding + +### Notification Fallback (Clarified) +``` +Priority 0: [webhook ✗, satori ✗] ──► ANY fails triggers fallback + ↓ +Priority 1: [mail] + ↓ +Priority N: [system-email, in-site] ──► Ultimate fallback (system-level) +``` +- `fallible: true` at group N = **stop propagation**, don't trigger group N+1 +- `fallible: true` at **last defined group** = no further notification attempts +- System-level fallback exists **outside** user config (configured at user creation) + +### Plugin Execution +``` +Artifact Collection (rsync to plugin workspace) + ↓ +Plugin receives: prefetched directory + ↓ +docker-push: ignores/prohibits compression: "dir" +``` + +### Config Override Behavior +```yaml +plugin: + docker-push: + auth: # ENTIRE key replaced + user: "default" + token: "xxx" + +publish: + docker-push: + auth: + user: "override" # { user: "override" } - token LOST +``` + +--- + +## Remaining Questions + +### A. Plugin Workspace & File Handling + +1. **Plugin Workspace Location** + - Where is the plugin workspace? `/workspace/.plugins/docker-push/`? + - Is this workspace shared across all artifacts using same plugin? + - Or isolated per artifact? + +2. **Large Artifact Handling** + - If artifact is 10GB, does rsync complete before plugin starts? + - Or does plugin stream from rsync? + - Is there a size limit/timeout for rsync? + +3. **Plugin Working Directory** + - Does plugin receive `path` as-is, or resolved absolute path? + - Example: `path: "/workspace/target/release/*"` → plugin sees `/workspace/.plugins/docker-push/release/`? + +### B. Artifact Validation + +4. **Glob Expansion Timing** + ```yaml + path: "/workspace/target/release/*" + ``` + - When is glob expanded? Config load? Execution time? + - If expanded at load time to 3 files, is it 3 artifacts or 1 artifact with 3 files? + +5. **Artifact ID Generation** + - Format: `pipeline-uuid-path-hash`? Or something else? + - Is ID deterministic (same path = same ID) or random? + +### C. Publish Execution Order + +6. **Multiple Plugins for One Artifact** + ```yaml + artifact: + - path: "/workspace/output.tar.gz" + publish: + docker-push: + image: "app:latest" + s3-upload: # Second plugin + bucket: "my-bucket" + ``` + - Execution order: sequential? parallel? configurable? + - If `docker-push` fails, does `s3-upload` run? + +### D. Notification Content Templates + +7. **Condition-Content Mapping** + ```yaml + satori: + on_finish_of: + stage: "prebake.ready" + content: "Prebake done" + on_success: + content: "Build succeeded" + on_failure: + content: "Build failed" + ``` + - If `prebake.ready` finishes but build later fails... + - Does `on_finish_of` send at `prebake.ready` time? + - Then `on_failure` sends at build end? + - Both messages sent, right? + +8. **Template Variable Scope** + ```yaml + on_finish_of: + stage: "prebake.ready" + content: "{{ build.status }}" # Not available yet? + ``` + - What happens when variable isn't available at that stage? + - Empty string? Error? `"unknown"`? + +### E. Error Recovery + +9. **Plugin Error Reporting** + - How does plugin report errors back to baker? + - Exit code? JSON output? Structured log? + - Is there a plugin API contract? + +10. **Retrying Individual Methods** + ```yaml + notification: + 0: + webhook: + retry: 3 # Hypothetical + retry_delay: 5s + ``` + - Will retry be per-method, per-group, or global? + - Or should plugins handle their own retry? + +### F. System-Level Fallback + +11. **System Notification Configuration** + - Where is system email configured? + - In prebake.yml? baker config? System-wide config? + - What if system email also fails? + +12. **In-Site Notification** + - Does this mean a web UI notification? + - Stored in database? Shown in baker dashboard? + +### G. Typo & Format Fixes + +13. **Proposed Fixes** + ```yaml + artifact: + - path: "/workspace/output.tar.gz" + retention: "7d" # Fixed typo + compression: "zstd" + on: + - success + ``` + - Confirm: `retension` → `retention` for final version? + +14. **Duration Format Unification** + ```yaml + # Option A: String with unit (current) + retention: "7d" + + # Option B: Structured + retention: + days: 7 + + # Option C: ISO 8601 + retention: "P7D" + ``` + - Preference for consistency with future `prebake.cache`? + +### H. Edge Cases + +15. **Empty Notification Group** + ```yaml + notification: + 0: {} # Empty group? + 1: + mail: ... + ``` + - Is empty group valid? Ignored? + - Or validation error? + +16. **Circular Plugin Reference** + - Can plugin A's output be used as plugin B's input? + - Example: docker-push → image scanner plugin + - Or should this be a single composed plugin? + +--- + +## Summary: Design Maturity + +| Component | Status | Notes | +|-----------|--------|-------| +| Plugin system | ✅ Clear | URI, override, fallible defined | +| Notification priority | ✅ Clear | Fallback semantics defined | +| Notification stages | ⚠️ WIP | `on_finish_of` structure defined, finalize stages TBD | +| Artifact collection | ✅ Clear | rsync to plugin workspace | +| Artifact publishing | ✅ Clear | Sequential/parallel WIP | +| Conditional system | ⚠️ WIP | Planned | +| Error handling | ⚠️ WIP | Timeout/retry TBD | +| System fallback | ✅ Clear | Email + in-site notification | +| Typo fix | ✅ Confirmed | `retention` | +| Duration format | ⚠️ TBD | Unify with prebake.cache | + +What's the next aspect to design or clarify? + +--- + +## User + +1. defined by plugin itself +2. rsync before plugin, UNLESS specified by plugin (maybe someone implements more robust method) +(not completely rsync thou, hard link/filesystem features may be used) +3. as 1 +4. glob->RE, checked when loading, compiled at running, 1s for total limitation(RE compile+match). one path means one artifact element +5. hash of PUID+PATH, maybe MD5 is enough +6. SHOULD be parallel, fails: see fallible +7. both sent, one first. +8. WIP as variable system not defined, just as example. build.status could mean "bake.fetch:3%" or "prebake:ready!" here. +9. plugin is shell scripts/ rhai scripts. Via API/CLI(or, function, for shell) and exit-code. Log will be captured but not as evidence +10. retry per method +11. system email contigured via Web+Server(not started) +If system email also fails: via in-site notification +If none works: we admit we have no way to notify user. We give up. Admin will receive this message if the configures to do so. +12. WebUI, stored in DB, in baker dashboard. +13. we'll fix it +14. ISO 8601 sounds good, P could be emitted because only period supported. +Who'd like to hard-code specific time into static config? +Maybe there is. +15. 0 should be eliminated and email evolve to 0 +every field could lie in any priority, thus webhook could lie in 999 +treat as a failure if 0 is none +16. make plugin callable w/o artifact +chain loading might be reconsidered, because push and check may share the same INPUT than OUTPUT + +--- + +## Assistant (Sisyphus (Ultraworker) · GLM-5 · 54.3s) + +## Final Design Synthesis + +### Artifact Collection Flow +``` +Glob Path → RE Compile (load) → Match (run, 1s timeout) → One artifact per match + ↓ + Hash(PUID + PATH) → MD5 as ID + ↓ + Hard-link/rsync to plugin workspace (plugin-defined location) + ↓ + Plugin executes (shell/rhai) + ↓ + Exit code = success/failure +``` + +### Notification Flow (Final) +``` +Priority 0: [webhook, satori] ──parallel──► ALL succeed = done + │ │ + │ └── ANY fails ──► Priority 1 + │ │ + ▼ ▼ + retry per method [mail] ──► ... + │ + ▼ + System fallback: in-site DB + │ + ▼ + Give up → Admin notification +``` + +### Plugin Architecture +``` +Plugin = Shell script | Rhai script + └── Communicates via: API/CLI/function calls + exit code + └── Log captured (informational, not evidence) + └── Workspace location: plugin-defined + └── File transfer: hard-link/rsync by default, plugin can override + └── Callable without artifact (standalone execution) +``` + +### Duration Format +```yaml +retention: "7D" # ISO 8601, P omitted +retention: "168H" # Hours also valid +retention: "604800" # Seconds (no unit) +``` + +--- + +## Final Questions + +### A. Plugin Without Artifact + +1. **Standalone Plugin Definition** + ```yaml + plugin: + health-check: # No artifact input + "git::github.com/user/health-plugin@main": + endpoint: "https://api.example.com" + + # How is it invoked? + ``` + - Where does standalone plugin get invoked? In `notification` block? + - Or separate `finalize.plugins:` section? + - Example syntax? + +2. **Standalone Plugin Output** + - Does standalone plugin produce anything? + - Or purely side-effect (HTTP call, etc.)? + +### B. Plugin Chain (Reconsidered) + +3. **Shared Input Pattern** + ``` + Artifact A ──► docker-push ──► (image in registry) + │ + └──► image-scanner ──► (scan results) + (same input: artifact A, not docker-push output) + ``` + - Both plugins receive same artifact input? + - Configuration example: + ```yaml + artifact: + - path: "/workspace/image.tar" + publish: + docker-push: { image: "app:latest" } + image-scanner: { severity: "high" } # Same input, parallel? + ``` + +### C. Priority Group Flexibility + +4. **Non-Sequential Priority Numbers** + ```yaml + notification: + 0: + webhook: ... + 999: + mail: ... + ``` + - Is 999 valid? Or auto-renumbered to 1? + - What about gaps: `0`, `5`, `10`? + - Integers only, or can use named priorities? (`high`, `low`?) + +5. **Empty Group Handling** + ```yaml + notification: + 0: {} + 1: + mail: ... + ``` + - Result: `0` eliminated, `mail` becomes `0`? + - Or: validation error at config load? + +### D. Plugin Language Details + +6. **Rhai vs Shell Choice** + - Is this a plugin-level declaration? + ```yaml + plugin: + my-plugin: + "git::..." + runtime: "rhai" # or "shell"? + ``` + - Or detected by file extension? (`plugin.rhai` vs `plugin.sh`) + +7. **Plugin API Contract** + - Shell: environment variables? CLI arguments? Stdin? + - Rhai: injected functions/objects? + - Example of plugin receiving artifact path and config? + +### E. Build Status Progress + +8. **Progress Reporting** + ``` + build.status = "bake.fetch:3%" + build.status = "prebake:ready!" + ``` + - Is this a real-time string that updates? + - Or structured: `{{ build.stage }}` + `{{ build.progress }}`? + - How does plugin/notification read live progress? + +### F. Configuration Validation + +9. **Early vs Late Validation** + - Glob → RE: checked at load time + - Plugin existence: checked at load or run? + - Secret reference (`{{ secret.xxx }}`): checked at load or run? + +10. **Schema Validation Scope** + - Is there a JSON Schema for finalize.yml? + - Or just Rust struct deserialization with custom validators? + +### G. Security Considerations + +11. **Plugin Trust Model** + - Plugins are arbitrary shell scripts from Git repos + - How is trust established? + - Allowlist? Signature verification? User confirmation on first use? + +12. **Secret Exposure to Plugins** + - `{{ secret.dockerhub.token }}` - passed to plugin how? + - Environment variable? File? CLI arg (visible in ps)? + - Plugin sandbox/isolation? + +--- + +## Design Maturity (Final) + +| Component | Status | Open Items | +|-----------|--------|------------| +| Plugin execution | ✅ | Trust model, secret passing | +| Plugin standalone | ⚠️ | Invocation syntax TBD | +| Plugin chain | ✅ | Shared input pattern confirmed | +| Artifact collection | ✅ | Hash ID, glob→RE | +| Notification priority | ✅ | Empty group handling | +| Notification fallback | ✅ | System fallback chain defined | +| Plugin runtime | ⚠️ | API contract details | +| Progress reporting | ⚠️ | Variable system WIP | +| Duration format | ✅ | ISO 8601, P omitted | +| Validation | ⚠️ | Schema definition | +| Typo | ✅ | `retention` confirmed | + +--- + +Is there anything else to clarify, or should we proceed to implementation planning? + +--- + +## User + +1. WIP +2. MAY produce anything, may become another artifact +however this is weird, users should do this in bake stage than finalize +3. plugins could own TOKEN/LOCK and release upon finish +eg docker-push acquires "IMAGE." token. +image-scanner could start in parallel but soon be paused for no token available. +4. integers only, 0~INT_MAX, we don't limit +if someone writes 2147483647, either a. as long as they are happy or b. rejected by their superior +5. mail should become 0 however we don't do this +we warn about empty group +6. runtime could be automatically detected +7. shell: via CLI and VAR +rhai: WIP +8. stage maybe means LAST FINAL state, progress is dynamic. +9. by design bakerd should fetch plugin as long as they're ready (git/http at prebake stage and file at finalize.begin) +assume nothing changed git/http when baking because they require hash. + + +--- + +## Assistant (Sisyphus (Ultraworker) · GLM-5 · 14.0s) + +--- + +## User + +1. WIP +2. MAY produce anything, may become another artifact +however this is weird, users should do this in bake stage than finalize +3. plugins could own TOKEN/LOCK and release upon finish +eg docker-push acquires "IMAGE." token. +image-scanner could start in parallel but soon be paused for no token available. +4. integers only, 0~INT_MAX, we don't limit +if someone writes 2147483647, either a. as long as they are happy or b. rejected by their superior +5. mail should become 0 however we don't do this +we warn about empty group +6. runtime could be automatically detected +7. shell: via CLI and VAR +rhai: WIP +8. stage maybe means LAST FINAL state, progress is dynamic. +9. by design bakerd should fetch plugin as long as they're ready (git/http at prebake stage and file at finalize.begin) +assume nothing changed git/http when baking because they require hash. +10. WIP +11. allow ANY plugin cus plugin is in environment(docker/VM usually) +bare-metal plugin requires more work and permission (PIP:Pipeline Implicit Parameters) +12. Via baker. +Isolation = how baker (and the whole bake process) is isolated. + +--- + +## Assistant (Sisyphus (Ultraworker) · GLM-5 · 61.4s) + +## New Discovery: Plugin Token/Lock System + +This is a significant design element! Let me clarify: + +### Plugin Coordination via Tokens +``` +Artifact A ──► docker-push ──► Acquire "IMAGE.docker.io/user/app" + │ │ + │ └── Holds token until done + │ + └──► image-scanner ──► Wait for "IMAGE.docker.io/user/app" + │ + └── Resumes when token released +``` + +--- + +## Follow-Up Questions + +### A. Token/Lock System + +1. **Token Naming Convention** + - `"IMAGE."` - is this a convention or built-in? + - Can plugins define custom token names? e.g., `"DEPLOY.production"` + - Is there a reserved namespace? (e.g., `"SYSTEM.*"`) + +2. **Token Scope** + ```yaml + artifact: + - path: "/workspace/app.tar" + publish: + docker-push: + image: "docker.io/user/app:v1" + # Implicitly acquires "IMAGE.docker.io/user/app:v1"? + + - path: "/workspace/app.tar" # Same artifact, different tag + publish: + docker-push: + image: "docker.io/user/app:latest" + # Different token? "IMAGE.docker.io/user/app:latest"? + ``` + - Is token per image URL? Per artifact? Per plugin instance? + - How does system know what token to acquire? + +3. **Token Declaration** + - Does plugin declare required tokens in its schema? + - Or baker infers from plugin config (e.g., `image` field)? + ```yaml + # In plugin definition? + plugin: + docker-push: + "git::...": + tokens: + - "IMAGE.{{ config.image }}" # Template? + ``` + +4. **Deadlock Prevention** + ```yaml + artifact: + - publish: + plugin-a: + token: "RESOURCE.X" # Acquires X, then Y + plugin-b: + token: "RESOURCE.Y" # Acquires Y, then X + ``` + - Does baker detect potential deadlocks? + - Or is this user's responsibility? + +5. **Token Timeout** + - If docker-push hangs forever, image-scanner waits forever? + - Is there a token acquisition timeout? + - Or inherit from plugin's `timeout` setting? + +### B. Plugin Fetch Timing + +6. **Prebake-Stage Plugin Fetch** + ``` + prebake: fetch git/http plugins + bake: (assume plugins unchanged) + finalize.begin: fetch file:// plugins + ``` + - Where are fetched plugins stored? `/workspace/.plugins/`? + - Are they verified against hash before execution? + +7. **file:// Plugin Path** + ```yaml + plugin: + my-plugin: + "file:///workspace/my-plugin.sh": + ``` + - `file://` means plugin is already in build environment? + - Or on baker server (different machine)? + - How does this work with Docker isolation? + +### C. Plugin Runtime Detection + +8. **Detection Logic** + ```yaml + "file:///plugin.sh" → shell + "file:///plugin.rhai" → rhai + "git::github.com/user/plugin@main/plugin.sh" → shell + ``` + - Detected by file extension in URI? + - What if no extension? Default to shell? + - Can user override detection? + ```yaml + plugin: + my-plugin: + "file:///plugin.bin": + runtime: "shell" # Force shell + ``` + +### D. Shell Plugin Interface + +9. **CLI Arguments Structure** + ```bash + # Example invocation? + ./plugin.sh \ + --artifact-path "/workspace/.plugins/docker-push/..." \ + --config '{"image": "docker.io/user/app:latest"}' \ + --secret-token "xxx" + ``` + - What arguments are passed? + - Config as JSON string? Environment variables? Both? + +10. **Environment Variables** + ```bash + ARTIFACT_PATH="/workspace/.plugins/..." + PLUGIN_CONFIG_IMAGE="docker.io/user/app:latest" + SECRET_DOCKERHUB_TOKEN="xxx" + ``` + - What's the naming convention? + - `PLUGIN_CONFIG_*` for config? `SECRET_*` for secrets? + +### E. Progress Reporting + +11. **Progress Variable Semantics** + ``` + build.status = "bake.fetch:3%" + ``` + - Is this a single string variable that updates? + - Or structured: `build.stage = "bake.fetch"`, `build.progress = 3`? + - Can plugin/notification access `{{ build.progress }}`? + +12. **Real-Time Access** + - Can notification's `on_finish_of` access live progress? + - Or only "last final state" at the moment of trigger? + +### F. Bare-Metal Plugin Security (PIP) + +13. **Pipeline Implicit Parameters (PIP)** + - What does this include? + - Permission flags? Sandboxing options? + - Example config? + +14. **Bare-Metal Plugin Isolation** + ```yaml + environment: + builder: "baremetal" + + finalize: + plugin: + docker-push: ... # Needs special handling? + ``` + - How does bare-metal differ from Docker/VM for plugins? + - What restrictions apply? + +--- + +## Summary: Token System Design (Emerging) + +```yaml +# Hypothetical explicit token declaration +plugin: + docker-push: + "git::...": + tokens: + acquire: ["IMAGE.{{ image }}"] # Acquired on start + release: ["IMAGE.{{ image }}"] # Released on finish + # Or implicit from 'image' field? + +artifact: + - path: "/workspace/app.tar" + publish: + docker-push: + image: "docker.io/user/app:latest" + # System infers: acquire "IMAGE.docker.io/user/app:latest" + + image-scanner: + image: "docker.io/user/app:latest" + # System infers: wait for "IMAGE.docker.io/user/app:latest" + # Or explicit: + wait_tokens: ["IMAGE.docker.io/user/app:latest"] +``` + +This is a powerful coordination mechanism! Should tokens be: +- **Implicit**: Inferred from plugin config fields +- **Explicit**: Declared by plugin author in plugin schema +- **Mixed**: Implicit with override capability + +What's your preference? + +--- + +## User + +Too many questions lol. +Maybe answering after actually working on finalize. +Lock/Token is managed by system so dead-lock detection should be easy. + +--- + +## Assistant (Sisyphus (Ultraworker) · GLM-5 · 23.6s) + +Fair point! Let me wrap up with a concise summary. + +--- + +## finalize.yml.tmpl Design Summary + +### Confirmed Decisions + +| Area | Decision | +|------|----------| +| **Version** | `version: "0.0.1"` | +| **Plugin URI** | `git::repo@branch/commit`, `file://`, more protocols later | +| **Plugin runtime** | Auto-detected by extension (`.sh` / `.rhai`) | +| **Plugin config** | Shallow override from `publish:` | +| **Plugin communication** | Shell: CLI args + ENV vars; Rhai: WIP | +| **Plugin isolation** | Via baker environment (Docker/VM = any plugin, bare-metal = PIP) | +| **Fallible** | Same semantics as bake.sh `@fallible` | +| **Notification priority** | Integers 0~INT_MAX, intra-group parallel, inter-group sequential fallback | +| **Notification fallback** | System-level: in-site DB → give up → admin | +| **Empty group** | Warn, don't auto-fix | +| **Artifact ID** | MD5(PUID + PATH), auto-generated if omitted | +| **Artifact path** | Glob → RE, 1 match = 1 artifact | +| **Artifact transfer** | Hard-link/rsync to plugin workspace | +| **Duration format** | ISO 8601 without P: `"7D"`, `"168H"`, or seconds | +| **Typo fix** | `retension` → `retention` | +| **Token/Lock** | System-managed, plugin coordination | + +### WIP / Deferred to Implementation + +- Standalone plugin invocation +- Conditional system (`if:` on artifacts/notifications) +- `on_finish_of` condition syntax +- Variable system (`{{ build.* }}`, `{{ target.* }}`) +- Retry/timeout per notification method +- Plugin token declaration (implicit vs explicit) +- Cleanup policies +- Schema validation + +--- + +Ready to start implementation when you are. Good session! + +--- + +Please read the conversation above and summarize. diff --git a/description.md b/description.md new file mode 100644 index 0000000..47d95e3 --- /dev/null +++ b/description.md @@ -0,0 +1,62 @@ +# HBW(HoneyBiscuitWorkshop) 项目规划与介绍 + +本项目为一个基于Rust的CI/CD系统,名为“蜜饼工坊”(HoneyBiscuitWorkshop)。 +该名称取材于明日方舟世界观中小刻爱吃的蜜饼和她的管理者火神大姐的工坊。 + +## 目标愿景 + +这个CI/CD的最终用途如下: +0. 首先是一个合格的CI/CD。其参考了concourse(concourse也是本项目的起始点与初衷,见Vol3/Story.md),意图实现其大部分功能。 +1. 软件包自动化 +接入AOSC的autobuild+abbs树,或者Arch Linux的ABS,实现软件包的自动构建与更新 +在Web/CLI(./workshop)中允许使用流水线模板快捷推送任务。示例: +./workshop push aosc:linux-kernel-6.19 +./workshop push arch:python-pytorch-rocm +2. 机器学习支持,支持CUDA/ROCm/Others方案运行机器学习/深度学习/大模型训练程序 +3. 对于部分工业软件/行业软件的支持,例如允许通过Vivado实现硬件设计远程综合,或者通过Blender渲染一个项目 +一定程度是2的延伸 + +## 特性设计 + +1. LLM Skill/MCP 适配 +允许通过Skill调用API/CLI(prefer!)与服务器通信,实现流水线自愈,流水线配置快速生成等 +2. 流水线进度感知 +具体说来程序可以通过读取的流水线日志来判断进行的进度,以估计剩余时间,或给出超时警告 +实现上尽可能避开大模型,使用纯粹机器学习/深度学习方法,TTR=1s,单流水线持久化数据不大于16KB +3. 与koishi(Koishi.js,一个聊天机器人管理项目,https://koishi.chat/zh-CN/)等结合,实现消息推送与流水线简单管理(如快捷重启,定时启动等) +4. 基于cgroups/JobObject(计划中)的任务资源管理与计数 +资源管理可以对任务资源进行限制 +资源计数可以对不同权限的账号计算配额 +5. prebake.yml, bake.sh, finalize.yml的最简流水线配置 +prebake.yml的模板于本目录给出,尽可能完善 +prebake为纯粹声明式配置,具有固定的用户自定义锚点,具有权限配置 +bake.sh的模板于本目录给出,仅供参考 +bake使用@decorator(私有)装饰器语法增强语义,主要是命令式,兼容纯shell +finalize.yml的模板正在设计,计划上支持shell/rhai/dylib(待Rust ABI稳定) +6. 静态二进制发布,支持各种架构,系统与C库 +我们尽可能为Rust Tier1架构做完整支持(Windows暂缓) +会尤其注重新兴架构: RISC-V和LoongArch的支持 +理论上不支持32位,但不会限制 +!!!不支持没有操作系统的裸机(nostd)!!! +7. 构建机支持裸机,容器,FireCracker虚拟机与自定义环境 +8. 可配置的构建缓存与环境缓存 +9. 本地流水线快捷验证 +可以在不借助服务器的情况下快速验证流水线语法的正确性,在借助本机容器/虚拟机的情况下进行试构建 +10. 本地文件夹推送构建 +可以推送本地的任何一个文件夹到服务器,其会根据.workshop中的规则文件/LLM自生成进行构建,支持基于哈希+分块的增量传输 +11. Vol1/2/3 用户/管理员/开发者文档 +CI/CD采用Web+CLI+Server+Multi (Agent+Baker.bin(Bakerd+Baker))设计。 +12. 最小化外部依赖 +可能且推荐的外部扩展包括: +- RustyVault/Vault: 凭据存储 +- Koishi: IM集成与通知 +- Repology: 跨发行版包名映射 +可能的外部扩展包括: +- SQL DB +- NoSQL DB +- MQ +13. 分发 +事实上单二进制是与有系统依赖相对的。分发时,server/agent/baker需要动态下载。 +除去官方分发(软件包或者统一软件包(如flatpak))外,通过curl+sh下载验证并安装server。 +server自动下载并验证正确的agent与baker,agent/baker可缓存。 +提供agent.tar与baker.tar便于离线部署 diff --git a/description.txt b/description.txt deleted file mode 100644 index 1945432..0000000 --- a/description.txt +++ /dev/null @@ -1,29 +0,0 @@ -我们正在实现一个基于Rust的CI/CD系统,名为'蜜饼工坊'(HoneyBiscuitWorkshop),取材于明日方舟世界观中小刻爱吃的蜜饼和她的管理者火神大姐的工坊。 -这个CI/CD的最终用途如下: -0. 一个合格的CICD当然可以DevOps -1. 软件包自动化,例如接入AOSC的autobuild+abbs树,或者Arch Linux的ABS,实现软件包的自动构建与更新 -2. 机器学习支持,支持CUDA/ROCm/Others方案运行机器学习/深度学习程序 -3. 对于部分工业软件/行业软件的支持,例如允许通过Vivado实现硬件设计远程综合 -这个CI/CD的特色如下: -1. 基于LLM的流水线自配置 -LLM可以读取关键文件/定义的文件/预设规则/上次生成的报错来生成流水线配置 -2. 流水线进度感知 -具体说来程序可以通过读取的流水线日志来判断进行的进度,以估计剩余时间,或给出超时警告 -3. 与koishi(Koishi.js,一个聊天机器人管理项目,https://koishi.chat/zh-CN/)等结合,实现消息推送,流水线简单管理 -通过koishijs与QQ机器人/Telegram机器人等配合,实现流水线成功/失败推送和快捷的重试等功能 -4. 基于cgroups/JobObject的任务资源管理与计数 -资源管理可以对任务资源进行限制,资源计数可以对不同权限的账号计算配额 -5. prebake.yml, bake.sh, finalize.yml的最简流水线配置,声明式+命令式结合,采用@decorator进行装饰,以增强流水线行为 -细节见下 -6. 单二进制发布,支持各种架构,系统与C库 -当然,程序不支持没有操作系统的裸机(nostd) -7. 构建机支持裸机,容器与FireCracker虚拟机 -8. 可配置的构建缓存与环境缓存 -9. 本地流水线快捷验证 -可以在不借助服务器的情况下快速验证流水线语法的正确性,在借助本机容器的情况下进行试构建 -10. 本地文件夹推送构建 -11. Vol1/2/3 用户/管理员/开发者文档 -可以推送本地的任何一个文件夹到服务器,其会根据预设规则/.workshop中的文件/LLM自生成进行构建,支持增量传输 -CI/CD采用Web+Server+Multi (Agent+Executor)设计。当然也有可能的CLI客户端用于上述目的。 -这个CI/CD可能会结合koishi, rustyvault(https://rustyvault.net,使用标准的Vault也可)等外部项目构建,当然我们希望减少对外部项目的依赖,尤其是数据库(如MySQL,意即,我们尽可能在SQLite解决问题)和消息队列(如RabbitMQ),但是不阻止部署者使用这些。 -finalize.yml的语法尚在设计 diff --git a/finalize.yml.tmpl b/finalize.yml.tmpl new file mode 100644 index 0000000..52f0c8e --- /dev/null +++ b/finalize.yml.tmpl @@ -0,0 +1,366 @@ +# finalize.yml.tmpl +# +# 蜜饼工坊 (HoneyBiscuitWorkshop) 构建流水线 - 完成阶段配置 +# +# 版本号,用于格式兼容性检查 +# 版本迁移系统内置,预期 1.0 后不再变更 schema +version: "0.0.1" + +# ============================================================================= +# 插件定义 +# ============================================================================= +# 插件在 finalize 阶段执行特定任务(如推送镜像、上传制品等) +# 插件可以是 Shell 脚本或 Rhai 脚本,由文件扩展名自动检测 +# +# 插件来源格式: +# git::@/ - Git 仓库(在 prebake 阶段拉取) +# https:: - HTTPS 下载(支持脚本直接下载) +# file:: - 本地文件(在 finalize.begin 阶段加载) +# http:// - HTTP 下载(计划支持) +# hg:: - Mercurial 仓库(计划支持) +# +# 插件隔离: +# - Docker/VM 环境:允许任意插件,由环境提供隔离 +# - Bare-metal 环境:需要额外权限配置(PIP: Pipeline Implicit Parameters) +# +# 插件通信: +# - Shell 脚本:通过命令行参数和环境变量接收配置 +# - Rhai 脚本:通过注入的对象/函数接收配置(WIP) +# - 返回值:通过退出码表示成功/失败 +# - 日志:会被捕获但不作为执行证据 +plugin: + # 插件逻辑名称,用于在 artifact.publish 和 notification 中引用 + # 名称必须唯一,不可重复定义 + docker-push: + # 插件来源 URI,包含版本锁定 + # @main/1234abcd 表示 main 分支的 1234abcd 提交 + # 如果该提交不在指定分支中,则执行失败 + source: "git::github.com/user/hbw-finalize-docker-push@main/1234abcd": + # 认证主机,仅用于认证目的 + # 如果 publish 中指定了 image 且包含主机部分,此处可省略 + host: "docker.io" + + # 用户凭证,支持模板变量 + # {{ variable.xxx }} 为用户配置值 + # {{ secret.xxx }} 为从 Vault 获取的密钥 + user: {{ variable.dockerhub.user }} + token: {{ secret.dockerhub.token }} + + # fallible: 是否允许失败 + # true - 插件失败不会导致构建失败,错误不会向上传播 + # false - 插件失败会导致构建失败 + # 语义与 bake.sh 的 @fallible 装饰器相同 + fallible: false + + # 镜像安全检查插件示例(HTTPS 下载) + image-check: + source: "https::example.com/trivy/trivy.sh": + user: {{ variable.trivy.user }} + password: {{ secret.trivy.password }} + fallible: true + + # 短信通知插件示例(本地 Rhai 脚本) + sms-notify: + source: "file::.workshop/plugins/sms-notify.rhai": + provider: "aliyun" + access_key: {{ secret.aliyun.sms_key }} + access_secret: {{ secret.aliyun.sms_secret }} + +# ============================================================================= +# 通知配置 +# ============================================================================= +# 通知采用优先级组机制: +# - 优先级为整数,范围 0 ~ INT_MAX +# - 数值越小优先级越高 +# - 同一优先级组内的通知方式并行执行 +# - 当组内任一方式失败时,触发下一优先级组(fallback) +# - fallible: true 可阻止错误传播,不触发 fallback +# +# 系统级 fallback(在用户配置之外): +# 1. 站内通知(存储在数据库,显示在 Baker Dashboard) +# 2. 系统邮件(用户创建时配置) +# 3. 放弃通知,管理员接收告警(如已配置) +# +# 优先级组为空时会产生警告,但不会自动修正 +# 建议将优先级设为 0、1、2... 等连续值 + +notification: + # 最高优先级组 - 首选通知方式 + 0: + # Webhook 通知 + webhook: + url: "https://example.com/hook" + # schema 定义负载格式: + # - "default": 使用插件内置的默认格式 + # - "custom": 自定义格式,需要提供 on_* 模板 + # 当设置 on_success/on_failure 时,schema 隐式为 "custom" + schema: "default" + + # Satori 协议通知(如 Koishi) + satori: + url: "http://127.0.0.1:5140/satori/v1/message.create" + token: "Bearer {{ secret.koishi_token }}" + schema: "custom" + + # on_success: 构建成功时发送的内容 + # 支持模板变量: + # {{ build.status }} - 构建状态 + # {{ build.duration }} - 构建耗时 + # {{ target.xxx }} - 目标相关变量 + on_success: | + { + "channel_id": "{{ target.group_id }}", + "content": "🍯 HBW 构建完成\n状态: {{ build.status }}\n耗时: {{ build.duration }}" + } + + # on_failure: 构建失败时发送的内容 + on_failure: | + { + "channel_id": "{{ target.group_id }}", + "content": "🍯 HBW 构建失败!\n状态: {{ build.status }}\n耗时: {{ build.duration }}" + } + + # on_finish_of: 在指定阶段完成时发送通知 + # 可用于在构建过程中发送进度通知 + # 阶段命名规则: + # - prebake/finalize: 第二字段为静态名称,如 "prebake.ready" + # - bake: 第二字段为 bake.sh 中的函数名,如 "bake.build" + # 每个条件有独立的 content,会在对应时机发送 + on_finish_of: + stage: "prebake.ready" + content: | + { + "channel_id": "{{ target.group_id }}", + "content": "🍯 HBW 准备完毕\n状态: {{ build.status }}\n耗时: {{ build.duration }}" + } + + # 次优先级组 - 备用通知方式(当优先级 0 中任一方式失败时触发) + 1: + # 邮件通知(内置插件) + mail: + to: "user@gmail.com" + from: + host: "mail.163.com" + mode: "smtp" + # 加密模式: + # - "TLS": 使用 TLS 加密(端口 465) + # - "StartTLS": 使用 STARTTLS(端口 587) + # - "no": 不加密(不推荐) + secure: "TLS" + username: {{ variable.mail.user }} + password: {{ secret.mail.password }} + schema: "default" + + # 插件也可以直接作为通知方式使用 + # 需要插件支持通知触发器(on_success/on_failure/on_finish_of) + sms-notify: + to: "+8610012345678" + schema: "custom" + on_success: "[HBW Notify] Build Succeeded: {{ build.status }} ({{ build.duration }})" + on_failure: "[HBW Notify] Build Failed: {{ build.status }} ({{ build.duration }})" + on_finish_of: + stage: "bake.build" + content: "[HBW Notify] Build Progress: {{ build.status }} ({{ build.duration }})" + fallible: true + +# ============================================================================= +# 制品定义 +# ============================================================================= +# 制品是构建产生的输出文件,可被存储、发布或清理 +# +# 制品 ID: +# - 可选,如未指定则由系统自动生成 +# - 格式:MD5(PUID + PATH) +# - PUID: Pipeline Unique Identifier +# +# 制品路径: +# - 支持通配符,会被编译为正则表达式 +# - 在配置加载时检查语法,在执行时编译和匹配 +# - 总限制时间 1 秒(编译 + 匹配) +# - 一个路径匹配 = 一个制品元素 +# - 空路径表示非文件系统制品(如 Docker 镜像) +# +# 制品传输: +# - 通过 hard-link/rsync 传输到插件工作空间 +# - 插件可自定义传输方式 + +artifact: + # 示例制品 1:普通文件制品 + - # 制品 ID,可选 + # 如不指定,系统自动生成 MD5(PUID + PATH) + id: "" + + # 制品路径,支持 glob 模式 + # 匹配的每个文件/目录作为独立制品 + path: "/workspace/target/release/*" + + # 保留时长 + # 支持格式: + # - ISO 8601 周期格式(省略 P):"7D", "168H" + # - 秒数(纯数字):604800 + # 所有格式等效,默认单位为秒 + retention: "7D" + + # 压缩方式: + # - "zstd": Zstandard 压缩(推荐) + # - "gzip": Gzip 压缩 + # - "none": 不压缩 + # - "dir": 保留为目录结构(用于需要完整目录树的场景) + compression: "zstd" + + # 触发条件: + # - "success": 构建成功时处理 + # - "failure": 构建失败时处理(WIP) + # - "always": 总是处理(WIP) + # 支持多个条件 + on: + - success + + # 示例制品 2:Docker 镜像制品 + - id: "" + + # 空路径表示非文件系统制品 + # 用于 Docker 镜像、远程制品等 + # 此类制品的"内容"由 publish 定义 + path: "" + + retention: "5D" + compression: "none" + + on: + - success + + # 发布配置 + # 制品可以发布到一个或多个目标 + # 多个发布目标并行执行(除非受 Token 约束) + publish: + # 引用 plugin 部分定义的插件名称 + docker-push: + # 覆盖/补充插件配置 + # 镜像名称,包含 registry 地址 + # 如 plugin 中已定义 host,此处可省略 registry 部分 + image: "docker.io/user/image:tag" + + # 同一制品可以发布到多个插件 + # 通过 Token/Lock 机制协调访问 + image-check: + image: "docker.io/user/image:tag" + +# ============================================================================= +# 钩子定义 +# ============================================================================= +# 在 finalize 阶段执行的前置/后置钩子 +# 可用于清理、预处理、后处理等操作 +# +# 执行时机: +# - early: 制品收集之前执行 +# - late: 所有操作完成后执行 +# +# 命令类型: +# - 普通命令:直接执行 shell 命令 +# - 插件命令:以 "plugin:" 开头,调用已定义的插件 + +hooks: + # 早期钩子:在制品收集之前执行 + early: + - # 钩子名称,用于日志和调试 + name: "cleanup temp files before artifact collection" + # 执行命令 + command: "rm **/.tmp" + # 工作目录 + working_dir: "/workspace" + + # 插件类型钩子示例 + - name: "Early hook internal plugin" + command: "plugin:early-hook-internal" + # 超时时间(秒) + timeout: 600 + + # 后期钩子:在所有操作完成后执行 + late: + - name: "Build completion notification" + command: "echo 'Build completed!'" + +# ============================================================================= +# 清理配置 +# ============================================================================= +# 清理策略控制构建完成后的资源回收 +# WIP: 更多选项待设计 + +cleanup: + # 清理策略: + # - "auto": 自动清理(默认) + # - "manual": 手动清理 + # - "on_failure": 仅失败时清理(WIP) + policy: "manual" + +# ============================================================================= +# 模板变量参考 +# ============================================================================= +# finalize.yml 支持以下模板变量 +# +# 用户配置: +# {{ variable.xxx }} - 用户在 Baker 中配置的值 +# +# 密钥(来自 Vault): +# {{ secret.xxx }} - 从 Vault 获取的密钥值 +# - 仅存在于内存中,不写入磁盘 +# - 日志中自动遮蔽 +# - 需要 PIPELINE_UNCOVER_SECRET 权限才能查看(需 2FA) +# +# 构建信息: +# {{ build.status }} - 构建状态(动态,表示最后确认的状态) +# {{ build.duration }} - 构建耗时 +# {{ build.stage }} - 当前/最后阶段 +# +# 目标信息: +# {{ target.xxx }} - 目标相关变量 +# +# 注意: +# - 变量可用性取决于执行阶段 +# - 在 prebake.ready 阶段,build.duration 可能不可用 +# - 使用不可用变量时行为 TBD + +# ============================================================================= +# 与 prebake.yml 和 bake.sh 的关系 +# ============================================================================= +# 完整的 HBW 流水线包含三个阶段: +# +# 1. prebake (prebake.yml) +# - 环境准备:Docker/Firecracker/Bare-metal +# - 依赖安装:系统包、语言包 +# - 环境变量设置 +# - 缓存配置 +# - 输出:准备好的构建环境 +# +# 2. bake (bake.sh) +# - 构建执行:编译、测试、打包 +# - 通过 @decorator 控制执行流程 +# - 输出:构建产物 +# +# 3. finalize (finalize.yml) +# - 制品收集:收集构建产物 +# - 制品发布:推送到目标位置 +# - 通知发送:通知构建结果 +# - 资源清理:回收临时资源 +# - 输出:发布的制品、通知记录 +# +# 数据流: +# prebake → bake → finalize +# 环境变量从 prebake 传递到 bake +# 制品从 bake 传递到 finalize +# Secret 在所有阶段可用(通过 {{ secret.xxx }}) + +# ============================================================================= +# 示例:完整流水线配置 +# ============================================================================= +# 项目 .workshop 目录通常包含: +# - prebake.yml 或 prebake.yml.tmpl +# - bake.sh 或 bake.sh.tmpl +# - finalize.yml 或 finalize.yml.tmpl +# - .env 环境变量(不应包含 secret) +# +# 模板文件(.tmpl 后缀)包含占位符,会在首次运行时生成实际配置 +# 系统会通过 LLM 或模板引擎填充这些占位符 + +# vim: set ft=yaml ts=2 sw=2 et: diff --git a/prebake.yml.tmpl b/prebake.yml.tmpl index 9a5e4b6..af13b14 100644 --- a/prebake.yml.tmpl +++ b/prebake.yml.tmpl @@ -6,6 +6,7 @@ # 留空:尚未定义,留作后续 # 版本号,用于格式兼容性检查 +# 到目前为止,"1.0"的版本号并不意味着schema就这么确定下来 version: "1.0" # 构建环境定义 @@ -238,3 +239,5 @@ hooks: metadata: description: "Rust project build environment" maintainer: "team@example.com" + +# vim: set ft=yaml ts=2 sw=2 et: diff --git a/workshop-baker/Cargo.lock b/workshop-baker/Cargo.lock index 8c3bf57..1a34cf6 100644 --- a/workshop-baker/Cargo.lock +++ b/workshop-baker/Cargo.lock @@ -88,6 +88,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -630,6 +636,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -782,6 +798,20 @@ dependencies = [ "const-random", ] +[[package]] +name = "duration-str" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027cd1402a609c71a9ac333c7e3d90ee042e7a131da1a83da8c60df323f12f61" +dependencies = [ + "chrono", + "rust_decimal", + "serde", + "thiserror 2.0.17", + "time", + "winnow", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -794,6 +824,22 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -936,6 +982,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1009,8 +1070,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1049,6 +1112,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.12" @@ -1142,6 +1211,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.4.0" @@ -1254,12 +1334,29 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", "futures-channel", "futures-core", @@ -1267,12 +1364,16 @@ dependencies = [ "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1439,6 +1540,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -1531,6 +1648,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "tokio-native-tls", + "url", +] + [[package]] name = "libc" version = "0.2.177" @@ -1623,6 +1767,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.25.1" @@ -1648,6 +1809,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num" version = "0.4.3" @@ -1683,9 +1853,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1918,12 +2088,56 @@ dependencies = [ "tokio", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2087,6 +2301,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plain" version = "0.2.3" @@ -2243,6 +2463,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -2365,6 +2591,46 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2403,6 +2669,16 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "num-traits", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2437,7 +2713,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pki-types", "schannel", "security-framework", @@ -2530,7 +2806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2765,6 +3041,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2777,6 +3056,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tar" version = "0.4.44" @@ -2843,9 +3143,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -2858,15 +3158,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2929,6 +3229,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3071,6 +3381,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3243,6 +3571,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3296,6 +3630,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -3428,6 +3775,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3629,15 +3987,19 @@ dependencies = [ "clap", "config", "criterion", + "duration-str", "env_logger", "futures-util", + "glob", "lazy_static", + "lettre", "libc", "log", "minijinja", "os_info", "privdrop", "regex", + "reqwest", "semver", "serde", "serde_json", diff --git a/workshop-baker/Cargo.toml b/workshop-baker/Cargo.toml index 204b86a..e0ab8af 100644 --- a/workshop-baker/Cargo.toml +++ b/workshop-baker/Cargo.toml @@ -26,9 +26,12 @@ cgroups-rs = "0.5.0" chrono = { version = "0.4.42", features = ["serde"] } clap = { version = "4.5.53", features = ["derive"] } config = "0.15.19" +duration-str = "0.21.0" env_logger = "0.11.8" +glob = "0.3.2" futures-util = "0.3.31" lazy_static = "1.5.0" +lettre = { version = "0.11", features = ["tokio1-native-tls"] } log = "0.4.28" minijinja = "2.14.0" regex = "1.12.2" @@ -47,6 +50,7 @@ uuid = { version = "1.19.0", features = ["v4"] } libc = "0.2" os_info = "3.14.0" privdrop = "0.5.6" +reqwest = { version = "0.12", features = ["json"] } which = "8.0.2" [dev-dependencies] diff --git a/workshop-baker/src/bake.rs b/workshop-baker/src/bake.rs index c467ace..fbb7b2a 100644 --- a/workshop-baker/src/bake.rs +++ b/workshop-baker/src/bake.rs @@ -32,7 +32,7 @@ pub async fn bake( let workspace = get_workspace(&prebake); let functions = parser::parse_script(&script_content)?; - let has_pipeline = functions.iter().any(|f| { + let has_pipeline = functions.pipeline_functions.iter().any(|f| { f.decorators .iter() .any(|d| matches!(d, decorator::Decorator::Pipeline)) @@ -76,10 +76,13 @@ pub async fn bake( )?; engine.execute_script(&script, &ctx, &event_tx).await?; } else { - let sorted_functions = schedule::sort_function(functions)?; + let sorted_functions = schedule::sort_function(functions.pipeline_functions)?; + let remaining = functions.remaining_code; for func in sorted_functions { + let mut modified_func = func.clone(); + modified_func.body = remaining.clone() + &func.body; let script = builder::build_script( - &func, + &modified_func, bake_base_path, &workspace, None, @@ -119,3 +122,19 @@ fn privileged(prebake_config: &PrebakeConfig) -> bool { let drop_after = get_drop_after(&prebake_config.security).unwrap_or_default(); PrebakeStage::Never == drop_after } + + +type TaskResult = Result<(), BakeError>; + +type TaskFn = Box TaskResult + Send>; + +// TODO: Learn closure and continue +// async fn run(f:F) -> TaskFn { +// let engine = Engine::new(); +// engine.execute_script +// } + +// fn with_timeout(f: F, duration: i32) -> TaskFn { + + +// } diff --git a/workshop-baker/src/bake/parser.rs b/workshop-baker/src/bake/parser.rs index 696970d..686a2d1 100644 --- a/workshop-baker/src/bake/parser.rs +++ b/workshop-baker/src/bake/parser.rs @@ -1,4 +1,4 @@ -use super::decorator::{Decorator, parse_decorator}; +use super::decorator::{parse_decorator, Decorator}; use crate::bake::BakeError; use lazy_static::lazy_static; use regex::Regex; @@ -28,9 +28,7 @@ impl Function { #[derive(Debug)] enum ParserState { Normal, - Decorator { - decorators: Vec, - }, + AfterDecorator, InFunction { name: String, decorators: Vec, @@ -39,22 +37,27 @@ enum ParserState { }, } -pub fn parse_script(text: &String) -> Result, BakeError> { - let mut functions = Vec::new(); +#[derive(Debug)] +pub struct ParsedScript { + pub pipeline_functions: Vec, + pub remaining_code: String, +} + +pub fn parse_script(text: &str) -> Result { + let mut pipeline_functions = Vec::new(); + let mut remaining_code = String::new(); let mut state = ParserState::Normal; + let mut pending_decorators: Vec = Vec::new(); for (line_number, line) in text.lines().enumerate() { - let line_number = line_number + 1; // For error reporting, make it 1-based - dbg!(&line_number, &line); - dbg!(&state); + let line_number = line_number + 1; match &mut state { ParserState::Normal => { if DECORATOR_HEADER_REGEX.find(line).is_some() { if let Some(decorator) = parse_decorator(line, line_number)? { - state = ParserState::Decorator { - decorators: vec![decorator], - }; + pending_decorators.push(decorator); } + state = ParserState::AfterDecorator; continue; } if FUNCTION_REGEX.find(line).is_some() @@ -69,40 +72,55 @@ pub fn parse_script(text: &String) -> Result, BakeError> { false, ) }; - let decorators_captured = if let ParserState::Decorator { decorators } = &state - { - decorators.clone() - } else { - Vec::new() - }; + let has_pipeline = pending_decorators + .iter() + .any(|d| matches!(d, Decorator::Pipeline)); let brace_depth = tokens.iter().filter(|&token| token == "{").count() as i16 - tokens.iter().filter(|&token| token == "}").count() as i16; - if brace_depth!=0 { + + if brace_depth != 0 { state = ParserState::InFunction { name, - decorators: decorators_captured, + decorators: pending_decorators.clone(), body: line.to_string() + "\n", brace_depth, }; - } - else{ - functions.push(Function { - name: name.clone(), - decorators: decorators_captured, - body: line.to_string() + "\n", - }); + } else { + if has_pipeline { + pipeline_functions.push(Function { + name: name.clone(), + decorators: pending_decorators.clone(), + body: line.to_string() + "\n", + }); + } else { + for dec in &pending_decorators { + remaining_code.push_str(&format!("# {}\n", dec)); + } + remaining_code.push_str(line); + remaining_code.push('\n'); + } + pending_decorators.clear(); state = ParserState::Normal; } + } else { + remaining_code.push_str(line); + remaining_code.push('\n'); } } - ParserState::Decorator { decorators } => { + ParserState::AfterDecorator => { + if line.trim().is_empty() { + continue; + } if DECORATOR_HEADER_REGEX.find(line).is_some() { if let Some(decorator) = parse_decorator(line, line_number)? { - decorators.push(decorator); + pending_decorators.push(decorator); } continue; } - else if FUNCTION_REGEX.find(line).is_some() + if line.trim().starts_with('#') { + continue; + } + if FUNCTION_REGEX.find(line).is_some() || FUNCTION_REGEX_WITHOUT_KEYWORD.find(line).is_some() { let tokens = Shlex::new(line).collect::>(); @@ -114,28 +132,49 @@ pub fn parse_script(text: &String) -> Result, BakeError> { false, ) }; + let has_pipeline = pending_decorators + .iter() + .any(|d| matches!(d, Decorator::Pipeline)); let brace_depth = tokens.iter().filter(|&token| token == "{").count() as i16 - tokens.iter().filter(|&token| token == "}").count() as i16; - if brace_depth!=0 { + + if brace_depth != 0 { state = ParserState::InFunction { name, - decorators: decorators.clone(), + decorators: pending_decorators.clone(), body: line.to_string() + "\n", brace_depth, }; - } - else{ - functions.push(Function { - name: name.clone(), - decorators: decorators.clone(), - body: line.to_string() + "\n", - }); + } else { + if has_pipeline { + pipeline_functions.push(Function { + name: name.clone(), + decorators: pending_decorators.clone(), + body: line.to_string() + "\n", + }); + } else { + for dec in &pending_decorators { + remaining_code.push_str(&format!("# {}\n", dec)); + } + remaining_code.push_str(line); + remaining_code.push('\n'); + } + pending_decorators.clear(); state = ParserState::Normal; } + continue; } - else { - state = ParserState::Normal; + eprintln!( + "Warning: Dangling decorator(s) at line {}. Treating as comments.", + line_number - 1 + ); + for dec in &pending_decorators { + remaining_code.push_str(&format!("# {}\n", dec)); } + pending_decorators.clear(); + state = ParserState::Normal; + remaining_code.push_str(line); + remaining_code.push('\n'); } ParserState::InFunction { name, @@ -143,25 +182,32 @@ pub fn parse_script(text: &String) -> Result, BakeError> { body, brace_depth, } => { + let has_pipeline = decorators.iter().any(|d| matches!(d, Decorator::Pipeline)); body.push_str(line); body.push('\n'); let tokens = Shlex::new(line).collect::>(); *brace_depth += tokens.iter().filter(|&token| token == "{").count() as i16; - dbg!(&brace_depth); *brace_depth -= tokens.iter().filter(|&token| token == "}").count() as i16; - dbg!(&brace_depth); if *brace_depth == 0 && !body.trim().is_empty() { - functions.push(Function { - name: name.clone(), - decorators: decorators.clone(), - body: body.clone(), - }); + if has_pipeline { + pipeline_functions.push(Function { + name: name.clone(), + decorators: decorators.clone(), + body: body.clone(), + }); + } else { + for dec in decorators { + remaining_code.push_str(&format!("# {}\n", dec)); + } + remaining_code.push_str(body); + } + pending_decorators.clear(); state = ParserState::Normal; } } } - dbg!(&state); } + if let ParserState::InFunction { name, brace_depth, .. } = state @@ -174,8 +220,8 @@ pub fn parse_script(text: &String) -> Result, BakeError> { ), }); } - if let ParserState::Decorator { decorators } = state { - let last_decorator = decorators.last().unwrap(); // Safe to unwrap because we only enter this state if we have at least one decorator + if !pending_decorators.is_empty() { + let last_decorator = pending_decorators.last().unwrap(); return Err(BakeError::ScriptGenerationFailed { script: "bake.sh".to_string(), reason: format!( @@ -184,9 +230,12 @@ pub fn parse_script(text: &String) -> Result, BakeError> { ), }); } - Ok(functions) -} + Ok(ParsedScript { + pipeline_functions, + remaining_code, + }) +} #[cfg(test)] mod tests { @@ -195,304 +244,224 @@ mod tests { #[test] fn test_empty_script() { let script = ""; - let functions = parse_script(&script.to_string()).unwrap(); - assert!(functions.is_empty()); + let result = parse_script(&script.to_string()).unwrap(); + assert!(result.pipeline_functions.is_empty()); + assert!(result.remaining_code.is_empty()); } #[test] fn test_only_comments() { let script = "# comment\n# another"; - let functions = parse_script(&script.to_string()).unwrap(); - assert!(functions.is_empty()); + let result = parse_script(&script.to_string()).unwrap(); + assert!(result.pipeline_functions.is_empty()); + assert_eq!(result.remaining_code.trim(), "# comment\n# another"); } #[test] - fn test_function_with_parens() { + fn test_function_with_parens_non_pipeline() { let script = "func() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].name, "func"); + let result = parse_script(&script.to_string()).unwrap(); + assert!(result.pipeline_functions.is_empty()); + assert!(result.remaining_code.contains("func()")); } #[test] - fn test_function_keyword_with_parens() { - let script = "function func() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].name, "func"); - } - - #[test] - fn test_function_keyword_without_parens() { - let script = "function func { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].name, "func"); - } - - #[test] - fn test_single_decorator() { + fn test_pipeline_function() { let script = "# @pipeline\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].decorators.len(), 1); - assert_eq!(functions[0].decorators[0], Decorator::Pipeline); + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); + assert!(result.remaining_code.is_empty()); } #[test] - fn test_multiple_decorators() { - let script = "# @pipeline\n# @fallible\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].decorators.len(), 2); - } - - #[test] - fn test_decorator_invalidated_by_empty_line() { - let script = "# @pipeline\n\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].decorators.is_empty()); - } - - #[test] - fn test_decorator_invalidated_by_code() { - let script = "# @pipeline\necho before\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].decorators.is_empty()); - } - - #[test] - fn test_decorator_after_function() { - let script = "# @pipeline\nfunc1() { echo 1; }\nfunc2() { echo 2; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 2); - assert_eq!(functions[0].decorators.len(), 1); - assert!(functions[1].decorators.is_empty()); - } - - #[test] - fn test_nested_braces() { - let script = "func() { if true; then { echo hi; } fi }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - } - - #[test] - fn test_multiline_function() { - let script = "func() {\necho line1\necho line2\n}"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].body.contains("echo line1")); - assert!(functions[0].body.contains("echo line2")); - } - - #[test] - fn test_multiple_functions() { - let script = "func1() { echo 1; }\nfunc2() { echo 2; }\nfunc3() { echo 3; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 3); - } - - #[test] - fn test_all_decorator_types() { - let script = "# @pipeline\n# @fallible\n# @loop(3)\n# @timeout(60)\n# @export\n# @parallel\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].decorators.len(), 6); - } - - #[test] - fn test_decorator_if() { - let script = "# @if(condition)\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!( - functions[0].decorators[0], - Decorator::If("condition".to_string()) - ); - } - - #[test] - fn test_decorator_retry() { - let script = "# @retry(3, 5)\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].decorators[0], Decorator::Retry(3, 5)); - } - - #[test] - fn test_decorator_pipe() { - let script = "# @pipe(step1, step2)\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!( - functions[0].decorators[0], - Decorator::Pipe(vec!["step1".to_string(), "step2".to_string()]) - ); - } - - #[test] - fn test_decorator_after_dep() { - let script = "# @after(other)\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!( - functions[0].decorators[0], - Decorator::After("other".to_string()) - ); - } - - #[test] - fn test_decorator_health() { - let script = "# @health(/health)\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!( - functions[0].decorators[0], - Decorator::Health("/health".to_string()) - ); - } - - #[test] - fn test_template_style_parallel_retry() { - let script = r#" + fn test_mixed_functions() { + let script = r#"#!/bin/bash +# comment +function helper() { echo "helper"; } # @pipeline -# @if(WS_PIPELINE_ARCH == amd64) -# @timeout(720) -# @retry(3, 5) -# @parallel -fetch_library() { - get_custom_library ffmpeg amd64 -} +prepare() { echo "prepare"; } +echo "Done." "#; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].decorators.len(), 5); + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "prepare"); + assert!(result.remaining_code.contains("# comment")); + assert!(result.remaining_code.contains("helper()")); + assert!(result.remaining_code.contains("echo \"Done.\"")); } #[test] - fn test_function_name_with_underscores_and_numbers() { - let script = "func_123_test() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].name, "func_123_test"); + fn test_only_pipeline_functions_remaining_empty() { + let script = "# @pipeline\nfunc1() { echo 1; }\n# @pipeline\nfunc2() { echo 2; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 2); + assert!(result.remaining_code.is_empty()); } #[test] - fn test_function_empty_body() { - let script = "empty_func() { }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].name, "empty_func"); - } + fn test_user_example() { + let script = r#"#!/bin/bash - #[test] - fn test_function_with_semicolons() { - let script = "multi() { echo a; echo b; echo c; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].body.contains("echo a;")); - } +#Implicit set -euo pipefail - #[test] - fn test_comment_between_decorator_and_function() { - let script = "# @pipeline\n# this is a comment\nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].decorators.is_empty()); - } +# function to be used by any pipeline stage +function get_custom_library() { + curl -LO https://archive.ppy.sh/$2/$1.tar.gz +} - #[test] - fn test_whitespace_only_script() { - let script = " \n\n\t\n "; - let functions = parse_script(&script.to_string()).unwrap(); - assert!(functions.is_empty()); - } +# @pipeline +prepare_dotnet() { + curl -L https://dot.net/v1/dotnet-install.sh | bash + bake_info "Successfully installed dotnet" +} - #[test] - fn test_function_without_keyword_no_parens() { - let script = "simple { echo hi; }"; +# @pipeline +fetch_source() { + git clone https://github.com/ppy/osu.git --depth 1 +} + +echo "Done." +"#; let result = parse_script(&script.to_string()); - assert!(result.is_err() || result.unwrap().is_empty()); + match result { + Ok(r) => { + assert_eq!( + r.pipeline_functions.len(), + 2, + "Expected 2 pipeline functions, got {}", + r.pipeline_functions.len() + ); + assert_eq!(r.pipeline_functions[0].name, "prepare_dotnet"); + assert_eq!(r.pipeline_functions[1].name, "fetch_source"); + } + Err(e) => { + panic!("Parse error: {:?}", e); + } + } } #[test] - fn test_malformed_no_closing_brace() { - let script = "incomplete() { echo hi;"; - let functions = parse_script(&script.to_string()); - assert!(functions.is_err()); + fn test_blank_lines_between_decorator_and_function() { + let script = "# @pipeline\n\nfunc() { echo 1; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); + assert!(result.remaining_code.is_empty()); } #[test] - fn test_decorator_with_whitespace_variations() { - let script = "#\t@pipeline \nfunc() { echo hi; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].decorators.len(), 1); + fn test_multiple_blank_lines_between_decorator_and_function() { + let script = "# @pipeline\n\n\nfunc() { echo 1; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_multiple_functions_with_mixed_decorators() { - let script = "# @pipeline\nfunc1() { echo 1; }\n# @parallel\nfunc2() { echo 2; }\nfunc3() { echo 3; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 3); - assert_eq!(functions[0].decorators.len(), 1); - assert_eq!(functions[1].decorators.len(), 1); - assert!(functions[2].decorators.is_empty()); + fn test_comments_between_decorator_and_function() { + let script = "# @pipeline\n# comment\nfunc() { echo 1; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_function_with_command_substitution() { - let script = "cmd_sub() { local x=$(date +%s); echo $x; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].body.contains("$(date")); + fn test_multiple_comments_between_decorator_and_function() { + let script = "# @pipeline\n# comment 1\n# comment 2\nfunc() { echo 1; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_function_with_case_statement() { - let script = "case_func() { case $x in a) echo a;; b) echo b;; esac; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].body.contains("case $x")); + fn test_mixed_blank_lines_and_comments_between_decorator_and_function() { + let script = "# @pipeline\n# comment\n\n# another comment\n\nfunc() { echo 1; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_very_short_function_names() { - let script = "a() { echo; }\nf() { echo; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 2); + fn test_shell_statement_after_decorator_warns() { + let script = "# @pipeline\necho \"Done.\""; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 0); + assert!(result.remaining_code.contains("# @pipeline")); + assert!(result.remaining_code.contains("echo \"Done.\"")); } #[test] - fn test_function_with_redirect() { - let script = "redirect_func() { echo hi > /tmp/out.txt; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].body.contains("> /tmp/out.txt")); + fn test_nested_braces_in_string() { + let script = r#"# @pipeline +func() { echo "{"; }"#; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_function_with_pipe_in_body() { - let script = "pipe_func() { cat file.txt | grep pattern | wc -l; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 1); - assert!(functions[0].body.contains("| grep")); + fn test_nested_braces_in_double_string() { + let script = r#"# @pipeline +func() { echo "{}"; }"#; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_decorator_at_end_of_script() { - let script = "func() { echo hi; }\n# @pipeline"; - let functions = parse_script(&script.to_string()); - assert!(functions.is_err()); + fn test_nested_braces_in_code() { + let script = r#"# @pipeline +func() { + if [ -z "${VAR}" ]; then + echo "empty" + fi +}"#; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].name, "func"); } #[test] - fn test_functions_with_only_comments_between() { - let script = "func1() { echo 1; }\n# comment\n# another\nfunc2() { echo 2; }"; - let functions = parse_script(&script.to_string()).unwrap(); - assert_eq!(functions.len(), 2); + fn test_function_with_curl_braces_url() { + let script = r#"# @pipeline +func() { + curl -LO https://example.com/file.tar.gz +}"#; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + } + + #[test] + fn test_function_with_json_like_content() { + let script = r#"# @pipeline +func() { echo '{"key": "value"}'; }"#; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + } + + #[test] + fn test_decorator_with_blank_lines_then_shell_statement() { + let script = "# @pipeline\n\n\necho \"Done.\""; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 0); + assert!(result.remaining_code.contains("# @pipeline")); + } + + #[test] + fn test_multiple_decorators_then_function() { + let script = "# @pipeline\n# @fallible\n\n\nfunc() { echo 1; }"; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 1); + assert_eq!(result.pipeline_functions[0].decorators.len(), 2); + } + + #[test] + fn test_multiple_decorators_then_shell_statement() { + let script = "# @pipeline\n# @fallible\necho \"Done.\""; + let result = parse_script(&script.to_string()).unwrap(); + assert_eq!(result.pipeline_functions.len(), 0); + assert!(result.remaining_code.contains("# @pipeline")); + assert!(result.remaining_code.contains("# @fallible")); } } diff --git a/workshop-baker/src/constant.rs b/workshop-baker/src/constant.rs index 30b47fc..93256e8 100644 --- a/workshop-baker/src/constant.rs +++ b/workshop-baker/src/constant.rs @@ -3,3 +3,4 @@ pub const BOOTSTRAP_SCRIPT_PATH: &str = "/bootstrap.sh"; pub const BAKE_SCRIPT_PATH: &str = "bake.sh"; pub const PIPELINE_SKIP_ERRORCODE: u8 = 233; pub const TRIVIAL_SCRIPT_NAME: &str = "BAKE_TRIVIAL"; +pub const BAKER_INTERNAL_CONFIG_PREFIX: &str = "_bakerinternal_"; diff --git a/workshop-baker/src/finalize.rs b/workshop-baker/src/finalize.rs index e69de29..f97adee 100644 --- a/workshop-baker/src/finalize.rs +++ b/workshop-baker/src/finalize.rs @@ -0,0 +1,49 @@ +pub mod config; +pub mod error; +pub mod stage; +pub mod plugin; +pub mod template; +pub mod event; + +use crate::cli::Cli; +use std::path::Path; +pub use config::*; +pub use error::FinalizeError; + +fn parse(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(FinalizeError::IoError)?; + let config: FinalizeConfig = serde_yaml::from_str(&content)?; + Ok(config) +} + +pub async fn finalize( + finalize_path: &Path, + cli: &Cli, +) -> Result<(), FinalizeError> { + // Initialize + let finalize = parse(finalize_path)?; + let validate_result = finalize.validate(); + if let Err(errors) = validate_result { + for error in &errors { + log::error!("Error: {}", error); + } + return Err(FinalizeError::ValidateError(errors)); + }; + + let mut ctx = crate::ExecutionContext { + pipeline_name: cli.pipeline.clone(), + task_id: format!("{}-{}-finalize", cli.pipeline, cli.build_id), + username: cli.username.clone(), + dry_run: cli.dry_run, + privileged: false, // really? + ..Default::default() + }; + let (tx, event_rx) = tokio::sync::mpsc::channel(256); + let event_tx: crate::engine::EventSender = Some(tx); + let pipeline_name = cli.pipeline.clone(); + let build_id = cli.build_id.clone(); + let receiver_handle = tokio::spawn(event::event_receiver(event_rx, pipeline_name, build_id)); + + // Register Plugins + todo!(); +} diff --git a/workshop-baker/src/finalize/config.rs b/workshop-baker/src/finalize/config.rs new file mode 100644 index 0000000..2ceac5f --- /dev/null +++ b/workshop-baker/src/finalize/config.rs @@ -0,0 +1,269 @@ +//! Finalize stage configuration types +//! +//! This module defines the configuration schema for the finalize stage, +//! which handles artifact collection, publishing, and notifications. + +use duration_str::deserialize_duration; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +/// Version requirement for finalize.yml schema compatibility +pub const VERSION_REQUIREMENT: &str = "^0.0.1"; + +pub type RawPluginMap = HashMap>; + +/// Root configuration for finalize stage +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct FinalizeConfig { + /// Schema version for compatibility checking + pub version: String, + + /// Plugin definitions + /// Map of logical plugin name -> plugin source URI -> plugin config + #[serde(default)] + pub plugin: RawPluginMap, + + /// Notification configurations organized by priority groups + /// Lower number = higher priority + /// Within same priority, notifications run in parallel + /// On failure, next priority group is triggered + #[serde(default)] + pub notification: HashMap>, + + /// Artifact definitions + #[serde(default)] + pub artifact: Vec, + + /// Cleanup configuration + #[serde(default)] + pub cleanup: CleanupConfig, +} + +/// Plugin configuration +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PluginConfig { + /// Whether plugin failure should not fail the build. + /// Same as @fallible decorator + #[serde(default)] + pub fallible: bool, + + /// Whether the plugin should be resolved after bake stage + #[serde(default)] + pub late_binding: bool, + + /// Baker runtime configuration + #[serde(skip)] + pub runtime: PluginRuntimeConfig, + + /// Plugin-specific configuration parameters. + /// These are passed to the plugin via according method. + #[serde(flatten)] + pub config: Value, +} + +#[derive(Debug, Clone, Default)] +pub struct PluginRuntimeConfig { + /// Error description if plugin is unavailable at runtime. + /// None means available + pub error: Option, + + /// Path of plugin directory in the environment + pub fspath: Option, + + /// manifest.yml + pub manifest: Option, +} + +/// Notification method configuration +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct NotificationMethod { + /// Whether notification failure should not trigger next priority group + #[serde(default)] + pub fallible: bool, + + /// Payload schema: "default" (plugin-defined) or "custom" (requires on_* templates) + #[serde(default)] + pub schema: Option, + + /// Content template for build success notification + #[serde(default)] + pub on_success: Option, + + /// Content template for build failure notification + #[serde(default)] + pub on_failure: Option, + + /// Stage-triggered notification configuration + #[serde(default)] + pub on_finish_of: Option, + + /// Notification-specific configuration (url, token, to, from, etc.) + #[serde(flatten)] + pub config: Value, +} + +/// Stage-triggered notification configuration +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct OnFinishOf { + /// Stage name to trigger on + /// Format: "prebake.ready", "bake.build", "finalize.artifact", etc. + pub stage: String, + + /// Content template for stage completion notification + pub content: String, +} + +/// Artifact definition +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ArtifactDef { + /// Artifact ID (optional, auto-generated as MD5(PUID + PATH) if not specified) + #[serde(default)] + pub id: Option, + + /// Artifact path (supports glob patterns, empty for non-filesystem artifacts like Docker images) + #[serde(default)] + pub path: String, + + /// Retention duration + /// Formats: "7D", "168H", "604800", "1w", "7 days" + #[serde( + default = "default_retention", + deserialize_with = "deserialize_duration" + )] + pub retention: Duration, + + /// Compression method: "zstd", "gzip", "none", "dir" + #[serde(default = "default_compression")] + pub compression: CompressionMethod, + + /// Trigger conditions: "success", "failure", "always" (WIP: failure, always) + #[serde(default = "default_on_conditions")] + pub on: Vec, + + /// Publishing targets + #[serde(default)] + pub publish: HashMap, +} + +impl Default for ArtifactDef { + fn default() -> Self { + Self { + id: None, + path: String::new(), + retention: default_retention(), + compression: default_compression(), + on: default_on_conditions(), + publish: HashMap::new(), + } + } +} + +/// Compression method for artifacts +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "lowercase")] +pub enum CompressionMethod { + #[default] + Zstd, + Gzip, + None, + Dir, +} + +/// Artifact trigger condition +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum ArtifactCondition { + Success, + Failure, + Always, +} + +/// Publish target configuration +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PublishTarget { + /// Whether publish failure should not fail the build + #[serde(default)] + pub fallible: bool, + + /// Conditional execution (WIP) + /// Format: "{{ env.BRANCH }} == 'main'" + #[serde(rename = "if", default)] + pub condition: Option, + + /// Plugin-specific configuration (image, bucket, etc.) + #[serde(flatten)] + pub config: Value, +} + +/// Cleanup configuration +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct CleanupConfig { + /// Cleanup policy: "auto", "manual", "on_failure" (WIP: manual, on_failure) + #[serde(default = "default_cleanup_policy")] + pub policy: CleanupPolicy, +} + +/// Cleanup policy +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "lowercase")] +pub enum CleanupPolicy { + #[default] + Auto, + Manual, + OnFailure, +} + +// Default value functions +fn default_retention() -> Duration { + Duration::from_secs(7 * 24 * 3600) +} + +fn default_compression() -> CompressionMethod { + CompressionMethod::Zstd +} + +fn default_on_conditions() -> Vec { + vec![ArtifactCondition::Success] +} + +fn default_cleanup_policy() -> CleanupPolicy { + CleanupPolicy::Auto +} + +impl FinalizeConfig { + /// Validate the finalize configuration + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + // Validate version + let version_requirement = + VersionReq::parse(VERSION_REQUIREMENT).expect("Invalid version requirement"); + let version = &self.version; + let version = match version.matches('.').count() { + 0 => format!("{}.0.0", version), + 1 => format!("{}.0", version), + _ => version.clone(), + }; + match Version::parse(&version) { + Ok(version) if version_requirement.matches(&version) => {} + Ok(version) => errors.push(format!( + "Version {} does not satisfy requirement {}", + version, VERSION_REQUIREMENT + )), + Err(e) => errors.push(format!("Invalid version format: {}", e)), + } + + // TODO: Schema-based validation for plugin configs + // TODO: Validate that publish targets reference existing plugins + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} diff --git a/workshop-baker/src/finalize/error.rs b/workshop-baker/src/finalize/error.rs new file mode 100644 index 0000000..7377ca8 --- /dev/null +++ b/workshop-baker/src/finalize/error.rs @@ -0,0 +1,64 @@ +use std::path::PathBuf; +use thiserror::Error; + +use crate::error::{ + EXITCODE_EXECUTION_ERROR, EXITCODE_GENERAL_ERROR, EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR, +}; + +#[derive(Error, Debug)] +pub enum FinalizeError { + #[error("Failed to parse finalize.yml: {0}")] + YamlParseError(#[from] serde_yaml::Error), + + #[error("Failed to validate finalize.yml: {0:?}")] + ValidateError(Vec), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Artifact not found: {0}")] + ArtifactNotFound(PathBuf), + + #[error("Plugin error: {0}")] + PluginError(#[from] PluginError), + + #[error("Plugin execution failed: {0}")] + PluginExecutionFailed(String), + + #[error("Notification failed: {0}")] + NotificationFailed(String), + + #[error("Template error: {0}")] + TemplateError(#[from] minijinja::Error), +} + +impl FinalizeError { + pub fn exit_code(&self) -> i32 { + match self { + FinalizeError::YamlParseError(_) => EXITCODE_PARSE_ERROR, + FinalizeError::ValidateError(_) => EXITCODE_PARSE_ERROR, + FinalizeError::IoError(_) => EXITCODE_IO_ERROR, + FinalizeError::ArtifactNotFound(_) => EXITCODE_GENERAL_ERROR, + FinalizeError::PluginError(_) => EXITCODE_GENERAL_ERROR, + FinalizeError::PluginExecutionFailed(_) => EXITCODE_EXECUTION_ERROR, + FinalizeError::NotificationFailed(_) => EXITCODE_GENERAL_ERROR, + FinalizeError::TemplateError(_) => EXITCODE_PARSE_ERROR, + } + } +} + +#[derive(Error, Debug)] +pub enum PluginError { + #[error("Unknown plugin: {0}")] + UnknownPlugin(String), + + #[error("Error: {0}")] + GeneralError(String), + + #[error("Plugin {name} is not available: {reason}")] + PluginUnavailable{ + name: String, + reason: String, + } + +} diff --git a/workshop-baker/src/finalize/event.rs b/workshop-baker/src/finalize/event.rs new file mode 100644 index 0000000..384bf69 --- /dev/null +++ b/workshop-baker/src/finalize/event.rs @@ -0,0 +1,36 @@ +use crate::engine::{ExecutionEvent, StreamType}; + +pub async fn event_receiver( + mut rx: tokio::sync::mpsc::Receiver, + pipeline_name: String, + build_id: String, +) { + while let Some(event) = rx.recv().await { + match event { + ExecutionEvent::TaskStarted { task_id, timestamp } => { + log::debug!( + "[{}] [{}] [{}] Task started: {}", + pipeline_name, build_id, timestamp, task_id + ); + } + ExecutionEvent::OutputChunk { task_id, stream, data } => { + match stream { + StreamType::Stdout => print!("[{}] [{}] [stdout] {}", pipeline_name, task_id, data), + StreamType::Stderr => eprint!("[{}] [{}] [stderr] {}", pipeline_name, task_id, data), + } + } + ExecutionEvent::TaskCompleted { task_id, result } => { + log::debug!( + "[{}] [{}] Task completed: {} (exit_code={}, duration={:?})", + pipeline_name, build_id, task_id, result.exit_code, result.duration + ); + } + ExecutionEvent::TaskFailed { task_id, error } => { + log::error!( + "[{}] [{}] Task failed: {} (error={})", + pipeline_name, build_id, task_id, error + ); + } + } + } +} diff --git a/workshop-baker/src/finalize/plugin.rs b/workshop-baker/src/finalize/plugin.rs new file mode 100644 index 0000000..bc7228e --- /dev/null +++ b/workshop-baker/src/finalize/plugin.rs @@ -0,0 +1,113 @@ +use crate::finalize::RawPluginMap; +use crate::{EventSender, ExecutionContext, finalize::error::PluginError}; +use async_trait::async_trait; +use std::collections::HashMap; + +mod internal; + +pub struct FinalizePlugin { + metadata: serde_yaml::Value, + argument: serde_yaml::Value, + pub entry: Box, +} + +impl FinalizePlugin { + pub fn new(entry: Box) -> Self { + Self { + metadata: serde_yaml::Value::Null, + argument: serde_yaml::Value::Null, + entry, + } + } + + pub fn with_metadata(mut self, metadata: serde_yaml::Value) -> Self { + self.metadata = metadata; + self + } + + pub fn with_argument(mut self, argument: serde_yaml::Value) -> Self { + self.argument = argument; + self + } + + pub async fn call(&self, ctx: &ExecutionContext, event_tx: &EventSender) -> PluginResult { + self.entry + .call(self.metadata.clone(), self.argument.clone(), ctx, event_tx) + .await + } +} + +type PluginMap = HashMap; +type PluginResult = Result<(), PluginError>; + +#[async_trait] +pub trait AsyncPluginFn: Send + Sync { + async fn call( + &self, + metadata: serde_yaml::Value, + argument: serde_yaml::Value, + ctx: &ExecutionContext, + event_tx: &EventSender, + ) -> PluginResult; +} + +pub fn register(external_plugins: RawPluginMap) -> Result { + let mut plugins: PluginMap = HashMap::new(); + + // Register internal plugin + for i in internal::get_internal_plugin() { + plugins.insert(i.name.to_string(), FinalizePlugin::new(i.entry)); + } + + // Register external plugin + for (name, data) in external_plugins.into_iter() { + let (_source, config) = data.into_iter().next().unwrap_or_else(|| { + panic!( + "Internal Error: Plugin {} has no source configured, this should have been caught by validation!", + name + ) + }); + // Assume every plugin prepared (or definitely unavailable) now + if let Some(reason) = config.runtime.error { + log::warn!("Plugin {} is not available: {}", name, reason); + continue; + } + plugins.insert( + name, + // TODO: Implement function for Shell & Rhai + // Use dummy function to please compiler + FinalizePlugin::new(Box::new(internal::dummy::Dummy)) + .with_metadata(config.runtime.manifest.unwrap_or_default()) + .with_argument(config.config), + ); + } + + Ok(plugins) +} + +/// Calls a registered plugin by name, passing the execution context and event sender. +/// +/// # Arguments +/// +/// * `plugins` - Map of registered plugin names to their [`FinalizePlugin`] instances +/// * `name` - Name of the plugin to invoke +/// * `ctx` - Execution context for the plugin +/// * `event_tx` - Event sender for plugin lifecycle events +/// +/// # Returns +/// +/// Returns `Ok(())` on successful plugin execution, or a [`PluginError`] if the plugin +/// is not found or execution fails. +pub async fn call_named_plugin( + plugins: &PluginMap, + name: String, + config: serde_yaml::Value, + ctx: &ExecutionContext, + event_tx: &EventSender, +) -> PluginResult { + let plugin = plugins + .get(&name) + .ok_or_else(|| PluginError::UnknownPlugin(name))?; + // TODO: Shallow argument merge + plugin.with_argument(config).call(ctx, event_tx).await +} diff --git a/workshop-baker/src/finalize/plugin/internal.rs b/workshop-baker/src/finalize/plugin/internal.rs new file mode 100644 index 0000000..5df4680 --- /dev/null +++ b/workshop-baker/src/finalize/plugin/internal.rs @@ -0,0 +1,31 @@ +use crate::finalize::plugin::AsyncPluginFn; +mod insitenotify; +mod mail; +mod satori; +mod webhook; +pub mod dummy; +pub struct InternalPlugin { + pub name: &'static str, + pub entry: Box, +} + +pub fn get_internal_plugin() -> Vec { + let mut internal_plugins: Vec = Vec::new(); + internal_plugins.push(InternalPlugin { + name: "in-site-notify", + entry: Box::new(insitenotify::InsiteNotify), + }); + internal_plugins.push(InternalPlugin { + name: "mail", + entry: Box::new(mail::Mail), + }); + internal_plugins.push(InternalPlugin { + name: "satori", + entry: Box::new(satori::Satori), + }); + internal_plugins.push(InternalPlugin { + name: "webhook", + entry: Box::new(webhook::Webhook), + }); + internal_plugins +} diff --git a/workshop-baker/src/finalize/plugin/internal/dummy.rs b/workshop-baker/src/finalize/plugin/internal/dummy.rs new file mode 100644 index 0000000..eb8184f --- /dev/null +++ b/workshop-baker/src/finalize/plugin/internal/dummy.rs @@ -0,0 +1,18 @@ +use crate::finalize::plugin::PluginError; +use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn}; +use async_trait::async_trait; + +pub struct Dummy; + +#[async_trait] +impl AsyncPluginFn for Dummy { + async fn call( + &self, + _metadata: serde_yaml::Value, + _argument: serde_yaml::Value, + _ctx: &ExecutionContext, + _event_tx: &EventSender, + ) -> Result<(), PluginError> { + Ok(()) + } +} diff --git a/workshop-baker/src/finalize/plugin/internal/insitenotify.rs b/workshop-baker/src/finalize/plugin/internal/insitenotify.rs new file mode 100644 index 0000000..0542d59 --- /dev/null +++ b/workshop-baker/src/finalize/plugin/internal/insitenotify.rs @@ -0,0 +1,17 @@ +use crate::{EventSender, ExecutionContext, finalize::{error::PluginError, plugin::AsyncPluginFn}}; +use async_trait::async_trait; + +pub struct InsiteNotify; + +#[async_trait] +impl AsyncPluginFn for InsiteNotify { + async fn call( + &self, + _metadata: serde_yaml::Value, + _argument: serde_yaml::Value, + _ctx: &ExecutionContext, + _event_tx: &EventSender, + ) -> Result<(), PluginError> { + unimplemented!("Requires workshop-base to be implemented!"); + } +} diff --git a/workshop-baker/src/finalize/plugin/internal/mail.rs b/workshop-baker/src/finalize/plugin/internal/mail.rs new file mode 100644 index 0000000..44d23c4 --- /dev/null +++ b/workshop-baker/src/finalize/plugin/internal/mail.rs @@ -0,0 +1,18 @@ +use crate::finalize::plugin::PluginError; +use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn}; +use async_trait::async_trait; + +pub struct Mail; + +#[async_trait] +impl AsyncPluginFn for Mail { + async fn call( + &self, + _metadata: serde_yaml::Value, + _argument: serde_yaml::Value, + _ctx: &ExecutionContext, + _event_tx: &EventSender, + ) -> Result<(), PluginError> { + Ok(()) + } +} diff --git a/workshop-baker/src/finalize/plugin/internal/satori.rs b/workshop-baker/src/finalize/plugin/internal/satori.rs new file mode 100644 index 0000000..5c0c2ee --- /dev/null +++ b/workshop-baker/src/finalize/plugin/internal/satori.rs @@ -0,0 +1,18 @@ +use crate::finalize::plugin::PluginError; +use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn}; +use async_trait::async_trait; + +pub struct Satori; + +#[async_trait] +impl AsyncPluginFn for Satori { + async fn call( + &self, + _metadata: serde_yaml::Value, + _argument: serde_yaml::Value, + _ctx: &ExecutionContext, + _event_tx: &EventSender, + ) -> Result<(), PluginError> { + Ok(()) + } +} diff --git a/workshop-baker/src/finalize/plugin/internal/webhook.rs b/workshop-baker/src/finalize/plugin/internal/webhook.rs new file mode 100644 index 0000000..5694f8f --- /dev/null +++ b/workshop-baker/src/finalize/plugin/internal/webhook.rs @@ -0,0 +1,18 @@ +use crate::finalize::plugin::PluginError; +use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn}; +use async_trait::async_trait; + +pub struct Webhook; + +#[async_trait] +impl AsyncPluginFn for Webhook { + async fn call( + &self, + _metadata: serde_yaml::Value, + _argument: serde_yaml::Value, + _ctx: &ExecutionContext, + _event_tx: &EventSender, + ) -> Result<(), PluginError> { + Ok(()) + } +} diff --git a/workshop-baker/src/finalize/stage.rs b/workshop-baker/src/finalize/stage.rs new file mode 100644 index 0000000..7de68fc --- /dev/null +++ b/workshop-baker/src/finalize/stage.rs @@ -0,0 +1,10 @@ +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum FinalizeStage { + Register, + EarlyHook, + Artifact, + Publish, + Notify, + LateHook, + Clean, +} diff --git a/workshop-baker/src/finalize/template.rs b/workshop-baker/src/finalize/template.rs new file mode 100644 index 0000000..d444c0d --- /dev/null +++ b/workshop-baker/src/finalize/template.rs @@ -0,0 +1,11 @@ +// WIP: Template resolution will be implemented as a shared module +// used by both prebake and finalize stages. +// See: workshop-baker/src/template/ (planned) +// +// Variable namespaces: +// {{ xxx }} - User config values (nested: dockerhub.user) +// {{ secret.xxx }} - Vault secrets +// {{ build.status }} - Build metadata +// {{ build.duration }} - Build duration in seconds +// {{ build.stage }} - Current/last stage +// {{ env.xxx }} - Environment variables diff --git a/workshop-baker/src/lib.rs b/workshop-baker/src/lib.rs index e62c511..d18fbaf 100644 --- a/workshop-baker/src/lib.rs +++ b/workshop-baker/src/lib.rs @@ -4,6 +4,7 @@ pub mod daemon; pub mod engine; pub mod error; +pub mod finalize; pub mod monitor; pub mod prebake; pub mod bake;