diff --git a/.core/go.yaml b/.core/go.yaml new file mode 100644 index 0000000..d56af81 --- /dev/null +++ b/.core/go.yaml @@ -0,0 +1,13 @@ +# .core/go.yaml — Go subtree marker for the Lethean canon. +# Presence of .core/ + go/ marks this repo as a managed canonical repo. +# Audit-sweep + dispatch tooling discover via this marker. +version: 1 + +project: + name: go-ansible + type: go + +go: + module: dappco.re/go/ansible + subtree: go + go_version: "1.26.0" diff --git a/.gitignore b/.gitignore index cdc6f76..fdbef7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ .idea/ .vscode/ *.log -.core/ +.core/workspace/ +.core/vm/ +.core/cache/ +.core/runtime/ +.core/*.duckdb* +.core/coderabbit-findings.txt diff --git a/CONSUMERS.md b/CONSUMERS.md deleted file mode 100644 index 8eae7b4..0000000 --- a/CONSUMERS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Consumers of go-ansible - -These modules import `dappco.re/go/ansible`: - -- go-infra - -**Breaking change risk: 1 consumers.** diff --git a/CONVENTION_DRIFT_REPORT.md b/CONVENTION_DRIFT_REPORT.md deleted file mode 100644 index f951bb5..0000000 --- a/CONVENTION_DRIFT_REPORT.md +++ /dev/null @@ -1,59 +0,0 @@ -# Convention Drift Report - -Date: 2026-03-23 -Branch: `agent/convention-drift-check--stdlib-core----u` - -`CODEX.md` is not present in this repository. Conventions were taken from `CLAUDE.md` and `docs/development.md`. - -Commands used for the test-gap pass: - -```bash -go test -coverprofile=/tmp/ansible.cover ./... -go tool cover -func=/tmp/ansible.cover -``` - -## `stdlib→core.*` - -No direct `stdlib`-to-`core.*` wrapper drift was found in the Go implementation. The remaining drift is stale migration residue around the `core.*` move: - -- `go.mod:15`, `go.sum:7`, `go.sum:8` - Legacy `forge.lthn.ai/core/go-log` references still remain in the dependency graph. -- `CLAUDE.md:37`, `docs/development.md:169` - Repository guidance still refers to `dappco.re/go command registry`, while the current command registration lives on the `dappco.re/go` API at `cmd/ansible/cmd.go:8`. -- `CLAUDE.md:66`, `docs/development.md:86` - Guidance still calls the logging package `go-log`, while production code imports `dappco.re/go/log` at `cmd/ansible/ansible.go:13`, `executor.go:15`, `modules.go:13`, `parser.go:12`, `ssh.go:16`. - -## UK English - -- `executor.go:248` - Comment uses US spelling: `Initialize host results`. -- `parser.go:321` - Comment uses US spelling: `NormalizeModule normalizes a module name to its canonical form.` -- `types.go:110` - Comment uses US spelling: `LoopControl controls loop behavior.` - -## Missing Tests - -- `cmd/ansible/ansible.go:17`, `cmd/ansible/ansible.go:29`, `cmd/ansible/ansible.go:163`, `cmd/ansible/cmd.go:8` - `go tool cover` reports `0.0%` coverage for the entire `cmd/ansible` package, so argument parsing, command registration, playbook execution wiring, and SSH test wiring have no tests. -- `executor.go:81`, `executor.go:97`, `executor.go:172`, `executor.go:210`, `executor.go:241`, `executor.go:307`, `executor.go:382`, `executor.go:420`, `executor.go:444`, `executor.go:499`, `executor.go:565` - The main execution path is still uncovered: top-level run flow, play execution, roles, host task scheduling, loops, block handling, includes, SSH client creation, and fact gathering are all `0.0%` in the coverage report. -- `parser.go:119` - `ParseRole` is `0.0%` covered. -- `ssh.go:77`, `ssh.go:187`, `ssh.go:200`, `ssh.go:276`, `ssh.go:283`, `ssh.go:377`, `ssh.go:396`, `ssh.go:410` - Only constructor/default behaviour is tested; the real SSH transport methods are all `0.0%` covered. -- `modules.go:17`, `modules.go:178`, `modules.go:206`, `modules.go:234`, `modules.go:253`, `modules.go:281`, `modules.go:324`, `modules.go:352`, `modules.go:420`, `modules.go:463`, `modules.go:480`, `modules.go:502`, `modules.go:526`, `modules.go:550`, `modules.go:584`, `modules.go:615`, `modules.go:652`, `modules.go:665`, `modules.go:690`, `modules.go:732`, `modules.go:743`, `modules.go:800`, `modules.go:835`, `modules.go:941`, `modules.go:989`, `modules.go:1013`, `modules.go:1042`, `modules.go:1120`, `modules.go:1139`, `modules.go:1172`, `modules.go:1209`, `modules.go:1283`, `modules.go:1288`, `modules.go:1306`, `modules.go:1357`, `modules.go:1408` - The real dispatcher and production module handlers are still `0.0%` covered. -- `mock_ssh_test.go:347`, `mock_ssh_test.go:433` - Existing module tests bypass `Executor.executeModule` and the production handlers by routing through `executeModuleWithMock` and duplicated shim implementations, so module assertions do not exercise the shipped code paths. -- `CLAUDE.md:60`, `docs/index.md:141`, `docs/index.md:142`, `modules.go:941`, `modules.go:989`, `modules.go:1120`, `modules.go:1139`, `modules.go:1283`, `modules.go:1288` - Documentation advertises support for `pause`, `wait_for`, `hostname`, `sysctl`, `setup`, and `reboot`, but there are no dedicated tests for those production handlers. - -## Missing SPDX Headers - -No tracked text file currently contains an SPDX header. - -- Repo metadata: `.github/workflows/ci.yml:1`, `.gitignore:1`, `go.mod:1`, `go.sum:1` -- Documentation: `CLAUDE.md:1`, `CONSUMERS.md:1`, `docs/architecture.md:1`, `docs/development.md:1`, `docs/index.md:1`, `kb/Executor.md:1`, `kb/Home.md:1` -- Go source: `cmd/ansible/ansible.go:1`, `cmd/ansible/cmd.go:1`, `executor.go:1`, `modules.go:1`, `parser.go:1`, `ssh.go:1`, `types.go:1` -- Go tests: `executor_extra_test.go:1`, `executor_test.go:1`, `mock_ssh_test.go:1`, `modules_adv_test.go:1`, `modules_cmd_test.go:1`, `modules_file_test.go:1`, `modules_infra_test.go:1`, `modules_svc_test.go:1`, `parser_test.go:1`, `ssh_test.go:1`, `types_test.go:1` diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/external/go b/external/go index d661b70..7c95f96 160000 --- a/external/go +++ b/external/go @@ -1 +1 @@ -Subproject commit d661b703e16183b3cbab101de189f688888a1174 +Subproject commit 7c95f964f84bd52c728c67c9cce49f1b9bf5e066 diff --git a/external/go-io b/external/go-io index 8d72624..40f5452 160000 --- a/external/go-io +++ b/external/go-io @@ -1 +1 @@ -Subproject commit 8d726243d1018ca85b7a55767f08c6d6f7dd9607 +Subproject commit 40f545248bb8c095b55673afb86cb0baf680a724 diff --git a/external/go-log b/external/go-log index c660f92..abafd06 160000 --- a/external/go-log +++ b/external/go-log @@ -1 +1 @@ -Subproject commit c660f9218fbdf6cfbb0de10fd85187c07d870328 +Subproject commit abafd065af5c919160d4e2d4ed26accd105b27c9 diff --git a/go.work b/go.work index 04ddfb1..624a634 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.26.2 // Workspace mode for development: pulls local sources from external/ submodules. // diff --git a/go/async_features_test.go b/go/async_features_test.go new file mode 100644 index 0000000..5bad8d3 --- /dev/null +++ b/go/async_features_test.go @@ -0,0 +1,413 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- newAsyncJobID --- + +func TestAsyncFeatures_NewAsyncJobID_Good_UniqueHex(t *core.T) { + a := newAsyncJobID() + b := newAsyncJobID() + core.AssertNotEmpty(t, a) + core.AssertNotEmpty(t, b) + // Two consecutive ids should not collide. + core.AssertNotEqual(t, a, b) +} + +// --- cloneAnyValue --- + +func TestAsyncFeatures_CloneAnyValue_Good_Scalars(t *core.T) { + core.AssertNil(t, cloneAnyValue(nil)) + core.AssertEqual(t, "x", cloneAnyValue("x")) + core.AssertEqual(t, 7, cloneAnyValue(7)) + core.AssertEqual(t, true, cloneAnyValue(true)) + core.AssertEqual(t, 1.5, cloneAnyValue(1.5)) +} + +func TestAsyncFeatures_CloneAnyValue_Ugly_NestedCollectionsAreIndependent(t *core.T) { + src := map[string]any{ + "list": []any{1, map[string]any{"deep": "v"}}, + "strs": []string{"a", "b"}, + "anys": map[any]any{1: "one"}, + } + clone, ok := cloneAnyValue(src).(map[string]any) + core.AssertTrue(t, ok) + + // Mutate the clone's nested structures. + clone["list"].([]any)[0] = 99 + clone["strs"].([]string)[0] = "z" + + // Source remains untouched. + core.AssertEqual(t, 1, src["list"].([]any)[0]) + core.AssertEqual(t, "a", src["strs"].([]string)[0]) +} + +func TestAsyncFeatures_CloneAnyValue_Bad_UnknownTypePassthrough(t *core.T) { + type custom struct{ n int } + in := custom{n: 3} + out := cloneAnyValue(in) + core.AssertEqual(t, in, out) +} + +// --- cloneAnyMap --- + +func TestAsyncFeatures_CloneAnyMap_Good_IndependentCopy(t *core.T) { + src := map[string]any{"k": "v", "nested": map[string]any{"x": 1}} + clone := cloneAnyMap(src) + core.AssertEqual(t, "v", clone["k"]) + + clone["nested"].(map[string]any)["x"] = 42 + core.AssertEqual(t, 1, src["nested"].(map[string]any)["x"]) +} + +func TestAsyncFeatures_CloneAnyMap_Bad_EmptyReturnsNil(t *core.T) { + core.AssertNil(t, cloneAnyMap(nil)) + core.AssertNil(t, cloneAnyMap(map[string]any{})) +} + +// --- cloneStringMap / cloneBoolMap --- + +func TestAsyncFeatures_CloneStringMap_Good_IndependentCopy(t *core.T) { + src := map[string]string{"PATH": "/usr/bin"} + clone := cloneStringMap(src) + clone["PATH"] = "/bin" + core.AssertEqual(t, "/usr/bin", src["PATH"]) +} + +func TestAsyncFeatures_CloneStringMap_Bad_EmptyReturnsNil(t *core.T) { + core.AssertNil(t, cloneStringMap(nil)) + core.AssertNil(t, cloneStringMap(map[string]string{})) +} + +func TestAsyncFeatures_CloneBoolMap_Good_IndependentCopy(t *core.T) { + src := map[string]bool{"web1": true} + clone := cloneBoolMap(src) + clone["web1"] = false + core.AssertTrue(t, src["web1"]) +} + +func TestAsyncFeatures_CloneBoolMap_Bad_EmptyReturnsNil(t *core.T) { + core.AssertNil(t, cloneBoolMap(nil)) + core.AssertNil(t, cloneBoolMap(map[string]bool{})) +} + +// --- cloneTaskForAsync / cloneTaskSlice --- + +func TestAsyncFeatures_CloneTaskForAsync_Good_DeepCopyMutableFields(t *core.T) { + src := &Task{ + Name: "install", + Module: "apt", + Args: map[string]any{"name": "nginx"}, + Vars: map[string]any{"v": 1}, + Environment: map[string]string{"E": "1"}, + Tags: []string{"deploy"}, + Block: []Task{{Name: "b1"}}, + Loop: []any{"a", "b"}, + IncludeRole: &RoleRef{Role: "web"}, + Apply: &TaskApply{Tags: []string{"t"}}, + LoopControl: &LoopControl{LoopVar: "item"}, + } + clone := cloneTaskForAsync(src) + core.AssertEqual(t, "install", clone.Name) + core.AssertEqual(t, "apt", clone.Module) + + // Mutate clone collections; source unchanged. + clone.Args["name"] = "apache" + clone.Tags[0] = "x" + clone.IncludeRole.Role = "db" + core.AssertEqual(t, "nginx", src.Args["name"]) + core.AssertEqual(t, "deploy", src.Tags[0]) + core.AssertEqual(t, "web", src.IncludeRole.Role) +} + +func TestAsyncFeatures_CloneTaskForAsync_Bad_NilReturnsZero(t *core.T) { + clone := cloneTaskForAsync(nil) + core.AssertEqual(t, "", clone.Name) + core.AssertNil(t, clone.Args) +} + +func TestAsyncFeatures_CloneTaskSlice_Ugly_EmptyAndPopulated(t *core.T) { + core.AssertNil(t, cloneTaskSlice(nil)) + core.AssertNil(t, cloneTaskSlice([]Task{})) + + src := []Task{{Name: "one", Args: map[string]any{"k": "v"}}, {Name: "two"}} + clone := cloneTaskSlice(src) + core.AssertLen(t, clone, 2) + clone[0].Args["k"] = "changed" + core.AssertEqual(t, "v", src[0].Args["k"]) +} + +// --- cloneRoleRef family --- + +func TestAsyncFeatures_CloneRoleRef_Good_DeepCopy(t *core.T) { + src := RoleRef{ + Role: "web", + Vars: map[string]any{"port": 80}, + Tags: []string{"deploy"}, + Apply: &TaskApply{Tags: []string{"t"}}, + When: []any{"cond"}, + } + clone := cloneRoleRef(src) + clone.Vars["port"] = 8080 + clone.Tags[0] = "x" + core.AssertEqual(t, 80, src.Vars["port"]) + core.AssertEqual(t, "deploy", src.Tags[0]) +} + +func TestAsyncFeatures_CloneRoleRefPtr_Bad_NilAndPopulated(t *core.T) { + core.AssertNil(t, cloneRoleRefPtr(nil)) + + src := &RoleRef{Role: "web", Vars: map[string]any{"a": 1}} + clone := cloneRoleRefPtr(src) + core.AssertNotNil(t, clone) + clone.Vars["a"] = 2 + core.AssertEqual(t, 1, src.Vars["a"]) +} + +func TestAsyncFeatures_CloneRoleRefSlice_Ugly_EmptyAndPopulated(t *core.T) { + core.AssertNil(t, cloneRoleRefSlice(nil)) + clone := cloneRoleRefSlice([]RoleRef{{Role: "a"}, {Role: "b"}}) + core.AssertLen(t, clone, 2) + core.AssertEqual(t, "a", clone[0].Role) +} + +// --- cloneTaskApplyPtr / cloneLoopControlPtr --- + +func TestAsyncFeatures_CloneTaskApplyPtr_Good_DeepCopy(t *core.T) { + src := &TaskApply{ + Tags: []string{"deploy"}, + Vars: map[string]any{"v": 1}, + Environment: map[string]string{"E": "1"}, + When: []any{"cond"}, + } + clone := cloneTaskApplyPtr(src) + clone.Tags[0] = "x" + clone.Vars["v"] = 2 + core.AssertEqual(t, "deploy", src.Tags[0]) + core.AssertEqual(t, 1, src.Vars["v"]) +} + +func TestAsyncFeatures_CloneTaskApplyPtr_Bad_Nil(t *core.T) { + core.AssertNil(t, cloneTaskApplyPtr(nil)) +} + +func TestAsyncFeatures_CloneLoopControlPtr_Good_ValueCopy(t *core.T) { + src := &LoopControl{LoopVar: "item", IndexVar: "idx"} + clone := cloneLoopControlPtr(src) + core.AssertNotNil(t, clone) + clone.LoopVar = "changed" + core.AssertEqual(t, "item", src.LoopVar) +} + +func TestAsyncFeatures_CloneLoopControlPtr_Bad_Nil(t *core.T) { + core.AssertNil(t, cloneLoopControlPtr(nil)) +} + +// --- cloneModuleDefaults / cloneHostVarsMap --- + +func TestAsyncFeatures_CloneModuleDefaults_Good_IndependentNested(t *core.T) { + src := map[string]map[string]any{"apt": {"update_cache": true}} + clone := cloneModuleDefaults(src) + clone["apt"]["update_cache"] = false + core.AssertEqual(t, true, src["apt"]["update_cache"]) +} + +func TestAsyncFeatures_CloneModuleDefaults_Bad_EmptyNil(t *core.T) { + core.AssertNil(t, cloneModuleDefaults(nil)) + core.AssertNil(t, cloneModuleDefaults(map[string]map[string]any{})) +} + +func TestAsyncFeatures_CloneHostVarsMap_Ugly_IndependentNested(t *core.T) { + core.AssertNil(t, cloneHostVarsMap(nil)) + src := map[string]map[string]any{"web1": {"port": 80}} + clone := cloneHostVarsMap(src) + clone["web1"]["port"] = 443 + core.AssertEqual(t, 80, src["web1"]["port"]) +} + +// --- cloneInventory / cloneInventoryGroup / cloneHost --- + +func TestAsyncFeatures_CloneInventory_Good_DeepCopyTree(t *core.T) { + src := &Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1", Vars: map[string]any{"role": "web"}}}, + Children: map[string]*InventoryGroup{ + "db": {Hosts: map[string]*Host{"db1": {AnsibleHost: "10.0.0.2"}}}, + }, + Vars: map[string]any{"env": "prod"}, + }, + HostVars: map[string]map[string]any{"web1": {"x": 1}}, + } + clone := cloneInventory(src) + core.AssertNotNil(t, clone) + core.AssertEqual(t, "10.0.0.1", clone.All.Hosts["web1"].AnsibleHost) + + // Mutate clone tree; source unchanged. + clone.All.Hosts["web1"].Vars["role"] = "db" + clone.All.Vars["env"] = "dev" + clone.All.Children["db"].Hosts["db1"].AnsibleHost = "9.9.9.9" + core.AssertEqual(t, "web", src.All.Hosts["web1"].Vars["role"]) + core.AssertEqual(t, "prod", src.All.Vars["env"]) + core.AssertEqual(t, "10.0.0.2", src.All.Children["db"].Hosts["db1"].AnsibleHost) +} + +func TestAsyncFeatures_CloneInventory_Bad_Nil(t *core.T) { + core.AssertNil(t, cloneInventory(nil)) + core.AssertNil(t, cloneInventoryGroup(nil)) + core.AssertNil(t, cloneHost(nil)) +} + +func TestAsyncFeatures_CloneHost_Ugly_VarsIndependent(t *core.T) { + src := &Host{AnsibleHost: "1.1.1.1", Vars: map[string]any{"k": "v"}} + clone := cloneHost(src) + clone.Vars["k"] = "changed" + clone.AnsibleHost = "2.2.2.2" + core.AssertEqual(t, "v", src.Vars["k"]) + core.AssertEqual(t, "1.1.1.1", src.AnsibleHost) +} + +// --- cloneFactsMap --- + +func TestAsyncFeatures_CloneFactsMap_Good_IndependentSnapshots(t *core.T) { + src := map[string]*Facts{"web1": {Hostname: "web1", CPUs: 4}} + clone := cloneFactsMap(src) + clone["web1"].CPUs = 8 + core.AssertEqual(t, 4, src["web1"].CPUs) +} + +func TestAsyncFeatures_CloneFactsMap_Ugly_NilEntryPreserved(t *core.T) { + core.AssertNil(t, cloneFactsMap(nil)) + src := map[string]*Facts{"web1": nil, "web2": {Hostname: "web2"}} + clone := cloneFactsMap(src) + core.AssertNil(t, clone["web1"]) + core.AssertEqual(t, "web2", clone["web2"].Hostname) +} + +// --- cloneResultsMap / cloneTaskResult / cloneTaskHandlersMap --- + +func TestAsyncFeatures_CloneTaskResult_Good_NestedLoopResults(t *core.T) { + src := &TaskResult{ + Changed: true, + Data: map[string]any{"k": "v"}, + Results: []TaskResult{{Changed: true, Data: map[string]any{"inner": 1}}}, + } + clone := cloneTaskResult(src) + core.AssertNotNil(t, clone) + clone.Data["k"] = "changed" + clone.Results[0].Data["inner"] = 99 + core.AssertEqual(t, "v", src.Data["k"]) + core.AssertEqual(t, 1, src.Results[0].Data["inner"]) +} + +func TestAsyncFeatures_CloneTaskResult_Bad_Nil(t *core.T) { + core.AssertNil(t, cloneTaskResult(nil)) +} + +func TestAsyncFeatures_CloneResultsMap_Ugly_EmptyHostSkipped(t *core.T) { + core.AssertNil(t, cloneResultsMap(nil)) + src := map[string]map[string]*TaskResult{ + "web1": {"r1": {Changed: true}}, + "empty": {}, + } + clone := cloneResultsMap(src) + core.AssertNotNil(t, clone["web1"]) + _, hasEmpty := clone["empty"] + core.AssertFalse(t, hasEmpty) +} + +func TestAsyncFeatures_CloneTaskHandlersMap_Good_IndependentSlices(t *core.T) { + core.AssertNil(t, cloneTaskHandlersMap(nil)) + src := map[string][]Task{"restart nginx": {{Name: "h1", Args: map[string]any{"k": "v"}}}} + clone := cloneTaskHandlersMap(src) + clone["restart nginx"][0].Args["k"] = "changed" + core.AssertEqual(t, "v", src["restart nginx"][0].Args["k"]) +} + +// --- clonePlayForAsync --- + +func TestAsyncFeatures_ClonePlayForAsync_Good_DeepCopyFields(t *core.T) { + gather := true + src := &Play{ + Name: "deploy", + Hosts: "web", + GatherFacts: &gather, + Vars: map[string]any{"v": 1}, + ModuleDefaults: map[string]map[string]any{"apt": {"x": 1}}, + PreTasks: []Task{{Name: "pre"}}, + Tasks: []Task{{Name: "t", Args: map[string]any{"k": "v"}}}, + PostTasks: []Task{{Name: "post"}}, + Roles: []RoleRef{{Role: "web"}}, + Handlers: []Task{{Name: "h"}}, + Tags: []string{"deploy"}, + Environment: map[string]string{"E": "1"}, + Serial: []any{1, 2}, + VarsFiles: []any{"vars.yml"}, + } + clone := clonePlayForAsync(src) + core.AssertNotNil(t, clone) + core.AssertEqual(t, "deploy", clone.Name) + + // GatherFacts pointer is a distinct copy. + *clone.GatherFacts = false + core.AssertTrue(t, *src.GatherFacts) + + clone.Vars["v"] = 2 + clone.Tasks[0].Args["k"] = "changed" + clone.Tags[0] = "x" + core.AssertEqual(t, 1, src.Vars["v"]) + core.AssertEqual(t, "v", src.Tasks[0].Args["k"]) + core.AssertEqual(t, "deploy", src.Tags[0]) +} + +func TestAsyncFeatures_ClonePlayForAsync_Bad_Nil(t *core.T) { + core.AssertNil(t, clonePlayForAsync(nil)) +} + +func TestAsyncFeatures_ClonePlayForAsync_Ugly_NilOptionalPointers(t *core.T) { + src := &Play{Name: "minimal", Hosts: "all"} + clone := clonePlayForAsync(src) + core.AssertNotNil(t, clone) + core.AssertNil(t, clone.GatherFacts) + core.AssertNil(t, clone.VarsFiles) + core.AssertEqual(t, "minimal", clone.Name) +} + +// --- cloneParser / cloneAsyncExecutor --- + +func TestAsyncFeatures_CloneParser_Good_IndependentVars(t *core.T) { + src := NewParser("/workspace") + src.vars = map[string]any{"k": "v"} + clone := cloneParser(src) + core.AssertNotNil(t, clone) + clone.vars["k"] = "changed" + core.AssertEqual(t, "v", src.vars["k"]) +} + +func TestAsyncFeatures_CloneParser_Bad_Nil(t *core.T) { + core.AssertNil(t, cloneParser(nil)) +} + +func TestAsyncFeatures_CloneAsyncExecutor_Good_IndependentMaps(t *core.T) { + e := &Executor{ + vars: map[string]any{"v": 1}, + notified: map[string]bool{"h": true}, + Tags: []string{"deploy"}, + Limit: "web", + CheckMode: true, + } + clone := e.cloneAsyncExecutor() + core.AssertNotNil(t, clone) + core.AssertEqual(t, "web", clone.Limit) + core.AssertTrue(t, clone.CheckMode) + + clone.vars["v"] = 2 + clone.Tags[0] = "x" + core.AssertEqual(t, 1, e.vars["v"]) + core.AssertEqual(t, "deploy", e.Tags[0]) +} + +func TestAsyncFeatures_CloneAsyncExecutor_Bad_Nil(t *core.T) { + var e *Executor + core.AssertNil(t, e.cloneAsyncExecutor()) +} diff --git a/go/cmd/ansible/cmd.go b/go/cmd/ansible/cmd.go index 50dae22..c02af9b 100644 --- a/go/cmd/ansible/cmd.go +++ b/go/cmd/ansible/cmd.go @@ -5,12 +5,15 @@ import ( ) // Register registers the `ansible` command and its `ansible/test` subcommand. +// Returns core.Result per the Mantis #1336 canonical Register signature +// — Ok(nil) on success. Callers may discard the result if they don't +// care about wiring failures. // // Example: // // app := core.New() -// Register(app) -func Register(c *core.Core) { +// if r := Register(app); !r.OK { return r } +func Register(c *core.Core) core.Result { c.Command("ansible", core.Command{ Description: "Run Ansible playbooks natively (no Python required)", Action: runPlaybookCommand, @@ -43,4 +46,6 @@ func Register(c *core.Core) { core.Option{Key: "port", Value: 22}, ), }) + + return core.Ok(nil) } diff --git a/go/cmd/ansible/command_result_test.go b/go/cmd/ansible/command_result_test.go new file mode 100644 index 0000000..299ab5d --- /dev/null +++ b/go/cmd/ansible/command_result_test.go @@ -0,0 +1,39 @@ +package ansiblecmd + +import ( + core "dappco.re/go" +) + +// --- commandResultStdout --- + +func TestCommandResult_Stdout_Good_ExtractsString(t *core.T) { + r := core.Ok(map[string]any{"stdout": "hello"}) + core.AssertEqual(t, "hello", commandResultStdout(r)) +} + +func TestCommandResult_Stdout_Bad_MissingOrWrongType(t *core.T) { + core.AssertEqual(t, "", commandResultStdout(core.Ok(map[string]any{}))) + core.AssertEqual(t, "", commandResultStdout(core.Ok(map[string]any{"stdout": 42}))) +} + +func TestCommandResult_Stdout_Ugly_NonMapValue(t *core.T) { + core.AssertEqual(t, "", commandResultStdout(core.Ok("not-a-map"))) +} + +// --- commandResultExitCode --- + +func TestCommandResult_ExitCode_Good_ExtractsInt(t *core.T) { + r := core.Ok(map[string]any{"exitCode": 0}) + core.AssertEqual(t, 0, commandResultExitCode(r)) + r = core.Ok(map[string]any{"exitCode": 2}) + core.AssertEqual(t, 2, commandResultExitCode(r)) +} + +func TestCommandResult_ExitCode_Bad_MissingDefaultsNegative(t *core.T) { + core.AssertEqual(t, -1, commandResultExitCode(core.Ok(map[string]any{}))) + core.AssertEqual(t, -1, commandResultExitCode(core.Ok(map[string]any{"exitCode": "2"}))) +} + +func TestCommandResult_ExitCode_Ugly_NonMapValue(t *core.T) { + core.AssertEqual(t, -1, commandResultExitCode(core.Ok(nil))) +} diff --git a/go/cmd/ansible/core_primitives_test.go b/go/cmd/ansible/core_primitives_test.go new file mode 100644 index 0000000..d2de32e --- /dev/null +++ b/go/cmd/ansible/core_primitives_test.go @@ -0,0 +1,56 @@ +package ansiblecmd + +import ( + core "dappco.re/go" +) + +// --- absPath / cleanPath --- + +func TestCorePrimitives_AbsPath_Good_AbsoluteCleaned(t *core.T) { + core.AssertEqual(t, "/srv/app", absPath("/srv/app")) + core.AssertEqual(t, "/srv/app", absPath("/srv/./app")) +} + +func TestCorePrimitives_AbsPath_Ugly_RelativeJoinsCwd(t *core.T) { + got := absPath("playbooks/site.yml") + core.AssertTrue(t, hasPrefix(got, "/") || contains(got, "playbooks/site.yml")) + core.AssertTrue(t, contains(got, "playbooks/site.yml")) +} + +func TestCorePrimitives_AbsPath_Bad_EmptyMatchesCwd(t *core.T) { + core.AssertEqual(t, core.Env("DIR_CWD"), absPath("")) +} + +func TestCorePrimitives_CleanPath_Good_NormalisesDots(t *core.T) { + core.AssertEqual(t, "a/b", cleanPath("a/./b")) + core.AssertEqual(t, ".", cleanPath("")) +} + +// --- repeat --- + +func TestCorePrimitives_Repeat_Good_BuildsString(t *core.T) { + core.AssertEqual(t, "==", repeat("=", 2)) +} + +func TestCorePrimitives_Repeat_Bad_NonPositive(t *core.T) { + core.AssertEqual(t, "", repeat("=", 0)) + core.AssertEqual(t, "", repeat("=", -1)) +} + +// --- fields --- + +func TestCorePrimitives_Fields_Good_SplitsOnWhitespace(t *core.T) { + core.AssertElementsMatch(t, []string{"a", "b", "c"}, fields(" a b\tc ")) +} + +func TestCorePrimitives_Fields_Bad_EmptyAndBlank(t *core.T) { + core.AssertLen(t, fields(""), 0) + core.AssertLen(t, fields(" \t "), 0) +} + +// --- println (comment-as-usage Example) --- + +func ExamplePrintln() { + println("ready", true) + // Output: ready true +} diff --git a/go/cmd/ansible/extravars_scalar_test.go b/go/cmd/ansible/extravars_scalar_test.go new file mode 100644 index 0000000..da8952d --- /dev/null +++ b/go/cmd/ansible/extravars_scalar_test.go @@ -0,0 +1,52 @@ +package ansiblecmd + +import ( + core "dappco.re/go" +) + +// --- parseExtraVarsScalar --- + +func TestExtraVarsScalar_ParseExtraVarsScalar_Good_TypedScalars(t *core.T) { + core.AssertEqual(t, 42, parseExtraVarsScalar("42")) + core.AssertEqual(t, true, parseExtraVarsScalar("true")) + core.AssertEqual(t, "plain", parseExtraVarsScalar("plain")) +} + +func TestExtraVarsScalar_ParseExtraVarsScalar_Bad_EmptyStaysEmpty(t *core.T) { + core.AssertEqual(t, "", parseExtraVarsScalar("")) +} + +func TestExtraVarsScalar_ParseExtraVarsScalar_Ugly_StructuredKeptAsRawString(t *core.T) { + // A YAML mapping or sequence is preserved as the raw string, not decoded, + // so structured extra-vars flow through the dedicated structured path. + core.AssertEqual(t, "{a: 1}", parseExtraVarsScalar("{a: 1}")) + core.AssertEqual(t, "[1, 2]", parseExtraVarsScalar("[1, 2]")) +} + +// --- trimCutset / containsRune / dirSep --- + +func TestExtraVarsScalar_TrimCutset_Good_BothEnds(t *core.T) { + core.AssertEqual(t, "core", trimCutset("''core''", "'")) + core.AssertEqual(t, "abc", trimCutset("xxabcxx", "x")) +} + +func TestExtraVarsScalar_TrimCutset_Bad_NoCutsetMatch(t *core.T) { + core.AssertEqual(t, "abc", trimCutset("abc", "z")) +} + +func TestExtraVarsScalar_ContainsRune_Good_PresentAndAbsent(t *core.T) { + core.AssertTrue(t, containsRune("abc", 'b')) + core.AssertFalse(t, containsRune("abc", 'z')) +} + +func TestExtraVarsScalar_DirSep_Good_DefaultsToSlash(t *core.T) { + // With no DS override the separator defaults to "/". + core.AssertEqual(t, "/", dirSep()) +} + +// --- print (comment-as-usage Example) --- + +func ExamplePrint() { + print("%s=%d\n", "n", 3) + // Output: n=3 +} diff --git a/go/core_primitives_test.go b/go/core_primitives_test.go new file mode 100644 index 0000000..0be6895 --- /dev/null +++ b/go/core_primitives_test.go @@ -0,0 +1,116 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- corexAbsPath / absPath --- + +func TestCorePrimitives_AbsPath_Good_AbsoluteStaysClean(t *core.T) { + // An already-absolute path is returned cleaned, independent of cwd. + core.AssertEqual(t, "/etc/hosts", absPath("/etc/hosts")) + core.AssertEqual(t, "/etc/hosts", absPath("/etc/./hosts")) +} + +func TestCorePrimitives_AbsPath_Ugly_RelativeJoinsCwd(t *core.T) { + // A relative path is joined onto the current working directory, so the + // result must end with the relative tail and be absolute. + got := corexAbsPath("sub/file.txt") + core.AssertTrue(t, hasSuffix(got, "sub/file.txt")) +} + +func TestCorePrimitives_AbsPath_Bad_EmptyReturnsCwd(t *core.T) { + // Empty input falls back to the configured cwd; both helper and alias agree. + core.AssertEqual(t, corexAbsPath(""), absPath("")) +} + +// --- corexRepeat / repeat --- + +func TestCorePrimitives_Repeat_Good_BuildsString(t *core.T) { + core.AssertEqual(t, "ababab", repeat("ab", 3)) + core.AssertEqual(t, "xxx", corexRepeat("x", 3)) +} + +func TestCorePrimitives_Repeat_Bad_NonPositiveCountEmpty(t *core.T) { + core.AssertEqual(t, "", repeat("ab", 0)) + core.AssertEqual(t, "", repeat("ab", -2)) +} + +func TestCorePrimitives_Repeat_Ugly_EmptySourceStillEmpty(t *core.T) { + core.AssertEqual(t, "", repeat("", 5)) +} + +// --- corexSprint / sprint --- + +func TestCorePrimitives_Sprint_Good_ConcatenatesArgs(t *core.T) { + core.AssertEqual(t, sprint("a", "b"), corexSprint("a", "b")) + core.AssertNotEmpty(t, sprint(1, 2, 3)) +} + +// --- corexWriteString / writeString --- + +func TestCorePrimitives_WriteString_Good_WritesToBuilder(t *core.T) { + buf := newBuilder() + writeString(buf, "hello ") + writeString(buf, "world") + core.AssertEqual(t, "hello world", buf.String()) +} + +// --- corexReplaceN / replaceN --- + +func TestCorePrimitives_ReplaceN_Good_LimitedReplacements(t *core.T) { + core.AssertEqual(t, "X-X-a", replaceN("a-a-a", "a", "X", 2)) +} + +func TestCorePrimitives_ReplaceN_Bad_ZeroOrEmptyOldIsNoop(t *core.T) { + core.AssertEqual(t, "a-a", replaceN("a-a", "a", "X", 0)) + core.AssertEqual(t, "a-a", replaceN("a-a", "", "X", 3)) +} + +func TestCorePrimitives_ReplaceN_Ugly_NegativeReplacesAll(t *core.T) { + core.AssertEqual(t, "X-X-X", replaceN("a-a-a", "a", "X", -1)) + // Needle not present leaves the string untouched even with a limit. + core.AssertEqual(t, "abc", replaceN("abc", "z", "Q", 2)) +} + +// --- corexStringIndex / corexStringLastIndex --- + +func TestCorePrimitives_StringIndex_Good_FindsFirst(t *core.T) { + core.AssertEqual(t, 2, stringIndex("aabaa", "b")) + core.AssertEqual(t, 0, stringIndex("abc", "")) +} + +func TestCorePrimitives_StringIndex_Bad_NotFoundAndTooLong(t *core.T) { + core.AssertEqual(t, -1, stringIndex("abc", "z")) + core.AssertEqual(t, -1, stringIndex("ab", "abcdef")) +} + +func TestCorePrimitives_StringLastIndex_Ugly_FindsLastAndEdges(t *core.T) { + core.AssertEqual(t, 3, stringLastIndex("aabaa", "a"+"a")) + core.AssertEqual(t, len("abc"), stringLastIndex("abc", "")) + core.AssertEqual(t, -1, stringLastIndex("ab", "zzz")) + core.AssertEqual(t, -1, stringLastIndex("abc", "z")) +} + +// --- corexCut / cut --- + +func TestCorePrimitives_Cut_Good_SplitsOnSep(t *core.T) { + before, after, found := cut("key=value", "=") + core.AssertTrue(t, found) + core.AssertEqual(t, "key", before) + core.AssertEqual(t, "value", after) +} + +func TestCorePrimitives_Cut_Bad_SepAbsent(t *core.T) { + before, after, found := cut("plain", "=") + core.AssertFalse(t, found) + core.AssertEqual(t, "plain", before) + core.AssertEqual(t, "", after) +} + +func TestCorePrimitives_Cut_Ugly_EmptySep(t *core.T) { + before, after, found := cut("plain", "") + core.AssertTrue(t, found) + core.AssertEqual(t, "", before) + core.AssertEqual(t, "plain", after) +} diff --git a/go/executor_lookup_test.go b/go/executor_lookup_test.go new file mode 100644 index 0000000..2a01b5a --- /dev/null +++ b/go/executor_lookup_test.go @@ -0,0 +1,163 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- isEmptyLoopValue --- + +func TestExecutorLookup_IsEmptyLoopValue_Good_EmptyKinds(t *core.T) { + core.AssertTrue(t, isEmptyLoopValue(nil)) + core.AssertTrue(t, isEmptyLoopValue(" ")) + core.AssertTrue(t, isEmptyLoopValue([]any{})) + core.AssertTrue(t, isEmptyLoopValue([]string{})) + core.AssertTrue(t, isEmptyLoopValue(map[string]any{})) +} + +func TestExecutorLookup_IsEmptyLoopValue_Bad_NonEmpty(t *core.T) { + core.AssertFalse(t, isEmptyLoopValue("x")) + core.AssertFalse(t, isEmptyLoopValue([]any{1})) + core.AssertFalse(t, isEmptyLoopValue(42)) +} + +func TestExecutorLookup_IsEmptyLoopValue_Ugly_ReflectKinds(t *core.T) { + core.AssertTrue(t, isEmptyLoopValue([]int{})) + core.AssertFalse(t, isEmptyLoopValue([]int{1})) + core.AssertTrue(t, isEmptyLoopValue(map[int]int{})) +} + +// --- firstFoundTerms --- + +func TestExecutorLookup_FirstFoundTerms_Good_StringAndSlice(t *core.T) { + files, paths := firstFoundTerms("a,b") + core.AssertElementsMatch(t, []string{"a", "b"}, files) + core.AssertNil(t, paths) + + files, _ = firstFoundTerms([]string{"x", "y"}) + core.AssertElementsMatch(t, []string{"x", "y"}, files) +} + +func TestExecutorLookup_FirstFoundTerms_Bad_Nil(t *core.T) { + files, paths := firstFoundTerms(nil) + core.AssertNil(t, files) + core.AssertNil(t, paths) +} + +func TestExecutorLookup_FirstFoundTerms_Ugly_MapFilesAndPaths(t *core.T) { + files, paths := firstFoundTerms(map[string]any{ + "files": []any{"a.conf", "b.conf"}, + "paths": []any{"/etc", "/opt"}, + }) + core.AssertElementsMatch(t, []string{"a.conf", "b.conf"}, files) + core.AssertElementsMatch(t, []string{"/etc", "/opt"}, paths) + + // Falls back to "terms" when "files" is absent. + files, _ = firstFoundTerms(map[any]any{"terms": "only.conf"}) + core.AssertElementsMatch(t, []string{"only.conf"}, files) +} + +// --- commandArgv --- + +func TestExecutorLookup_CommandArgv_Good_StringSlice(t *core.T) { + out := commandArgv(map[string]any{"argv": []string{"ls", "-l", ""}}) + core.AssertElementsMatch(t, []string{"ls", "-l"}, out) +} + +func TestExecutorLookup_CommandArgv_Bad_MissingKey(t *core.T) { + core.AssertNil(t, commandArgv(map[string]any{})) + core.AssertNil(t, commandArgv(map[string]any{"argv": ""})) +} + +func TestExecutorLookup_CommandArgv_Ugly_AnySliceAndScalar(t *core.T) { + out := commandArgv(map[string]any{"argv": []any{"echo", 1, nil}}) + core.AssertElementsMatch(t, []string{"echo", "1"}, out) + + out = commandArgv(map[string]any{"argv": "whoami"}) + core.AssertElementsMatch(t, []string{"whoami"}, out) +} + +// --- gatherSubsetKeys --- + +func TestExecutorLookup_GatherSubsetKeys_Good_KnownSubsets(t *core.T) { + core.AssertLen(t, gatherSubsetKeys("all"), 10) + core.AssertLen(t, gatherSubsetKeys("hardware"), 4) + core.AssertElementsMatch(t, []string{"ansible_default_ipv4_address"}, gatherSubsetKeys("network")) +} + +func TestExecutorLookup_GatherSubsetKeys_Bad_Unknown(t *core.T) { + core.AssertNil(t, gatherSubsetKeys("bogus")) +} + +// --- passwordLookupCharset --- + +func TestExecutorLookup_PasswordLookupCharset_Good_NamedSets(t *core.T) { + core.AssertEqual(t, "0123456789", passwordLookupCharset("digits")) + core.AssertEqual(t, "abcdefghijklmnopqrstuvwxyz", passwordLookupCharset("ascii_lowercase")) +} + +func TestExecutorLookup_PasswordLookupCharset_Bad_Empty(t *core.T) { + core.AssertEqual(t, "", passwordLookupCharset("")) +} + +func TestExecutorLookup_PasswordLookupCharset_Ugly_DedupAcrossSets(t *core.T) { + // hexdigits + digits share 0-9; duplicates are collapsed. + got := passwordLookupCharset("digits,hexdigits") + core.AssertContains(t, got, "abcdef") + core.AssertContains(t, got, "0123456789") + // Each rune appears once: count '0'. + count := 0 + for _, r := range got { + if r == '0' { + count++ + } + } + core.AssertEqual(t, 1, count) +} + +// --- parsePasswordLookupSpec --- + +func TestExecutorLookup_ParsePasswordLookupSpec_Good_PathAndOptions(t *core.T) { + spec := parsePasswordLookupSpec("/tmp/secret length=30 chars=digits seed=abc") + core.AssertEqual(t, "/tmp/secret", spec.path) + core.AssertEqual(t, 30, spec.length) + core.AssertEqual(t, "0123456789", spec.chars) + core.AssertEqual(t, "abc", spec.seed) +} + +func TestExecutorLookup_ParsePasswordLookupSpec_Bad_InvalidLengthIgnored(t *core.T) { + spec := parsePasswordLookupSpec("/tmp/secret length=notnum") + // Invalid length leaves the default in place. + core.AssertEqual(t, 20, spec.length) + core.AssertEqual(t, "/tmp/secret", spec.path) +} + +func TestExecutorLookup_ParsePasswordLookupSpec_Ugly_DefaultsWhenBare(t *core.T) { + spec := parsePasswordLookupSpec("") + core.AssertEqual(t, 20, spec.length) + core.AssertEqual(t, "", spec.path) + core.AssertNotEmpty(t, spec.chars) +} + +// --- generatePassword --- + +func TestExecutorLookup_GeneratePassword_Good_SeededDeterministic(t *core.T) { + r1 := generatePassword(12, "abcdef", "fixed-seed") + r2 := generatePassword(12, "abcdef", "fixed-seed") + core.RequireTrue(t, r1.OK) + core.RequireTrue(t, r2.OK) + core.AssertEqual(t, r1.Value.(string), r2.Value.(string)) + core.AssertLen(t, r1.Value.(string), 12) +} + +func TestExecutorLookup_GeneratePassword_Ugly_DefaultsApplied(t *core.T) { + // Non-positive length and empty charset both fall back to defaults. + r := generatePassword(0, "", "seed") + core.RequireTrue(t, r.OK) + core.AssertLen(t, r.Value.(string), 20) +} + +func TestExecutorLookup_GeneratePassword_Good_UnseededRandom(t *core.T) { + r := generatePassword(16, "abcdef", "") + core.RequireTrue(t, r.OK) + core.AssertLen(t, r.Value.(string), 16) +} diff --git a/go/executor_sequence_test.go b/go/executor_sequence_test.go new file mode 100644 index 0000000..25df4a7 --- /dev/null +++ b/go/executor_sequence_test.go @@ -0,0 +1,169 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- sequenceSpecInt --- + +func TestExecutorSequence_SequenceSpecInt_Good_NumericKinds(t *core.T) { + n, ok := sequenceSpecInt(5) + core.AssertTrue(t, ok) + core.AssertEqual(t, 5, n) + + n, ok = sequenceSpecInt(int64(9)) + core.AssertTrue(t, ok) + core.AssertEqual(t, 9, n) + + n, ok = sequenceSpecInt("12") + core.AssertTrue(t, ok) + core.AssertEqual(t, 12, n) +} + +func TestExecutorSequence_SequenceSpecInt_Bad_NonNumeric(t *core.T) { + _, ok := sequenceSpecInt("abc") + core.AssertFalse(t, ok) + _, ok = sequenceSpecInt(1.5) + core.AssertFalse(t, ok) + _, ok = sequenceSpecInt(nil) + core.AssertFalse(t, ok) +} + +func TestExecutorSequence_SequenceSpecInt_Ugly_UnsignedKinds(t *core.T) { + n, ok := sequenceSpecInt(uint16(7)) + core.AssertTrue(t, ok) + core.AssertEqual(t, 7, n) + + n, ok = sequenceSpecInt(uint8(255)) + core.AssertTrue(t, ok) + core.AssertEqual(t, 255, n) +} + +// --- parseSequenceSpec --- + +func TestExecutorSequence_ParseSequenceSpec_Good_StringStartEnd(t *core.T) { + r := parseSequenceSpec("start=1 end=3") + core.RequireTrue(t, r.OK) + spec := r.Value.(*sequenceSpec) + core.AssertEqual(t, 1, spec.start) + core.AssertEqual(t, 3, spec.end) + core.AssertTrue(t, spec.hasEnd) +} + +func TestExecutorSequence_ParseSequenceSpec_Good_CountDerivesEnd(t *core.T) { + r := parseSequenceSpec(map[string]any{"start": 2, "count": 3, "stride": 2}) + core.RequireTrue(t, r.OK) + spec := r.Value.(*sequenceSpec) + // end = start + (count-1)*step = 2 + 2*2 = 6 + core.AssertEqual(t, 6, spec.end) + core.AssertTrue(t, spec.hasEnd) +} + +func TestExecutorSequence_ParseSequenceSpec_Bad_MissingEndAndCount(t *core.T) { + r := parseSequenceSpec("start=1") + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "end or count") +} + +func TestExecutorSequence_ParseSequenceSpec_Bad_ZeroStride(t *core.T) { + r := parseSequenceSpec(map[string]any{"start": 1, "end": 5, "stride": 0}) + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "stride must not be zero") +} + +func TestExecutorSequence_ParseSequenceSpec_Bad_NonNumericStart(t *core.T) { + r := parseSequenceSpec(map[string]any{"start": "x", "end": 5}) + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "start must be numeric") +} + +func TestExecutorSequence_ParseSequenceSpec_Ugly_UnsupportedType(t *core.T) { + r := parseSequenceSpec(42) + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "string or mapping") +} + +func TestExecutorSequence_ParseSequenceSpec_Ugly_DescendingFlipsStep(t *core.T) { + // end < start with positive step -> parser flips the step negative. + r := parseSequenceSpec(map[string]any{"start": 5, "end": 1, "stride": 1}) + core.RequireTrue(t, r.OK) + spec := r.Value.(*sequenceSpec) + core.AssertLess(t, spec.step, 0) +} + +// --- buildSequenceValues / formatSequenceValue --- + +func TestExecutorSequence_BuildSequenceValues_Good_Ascending(t *core.T) { + spec := &sequenceSpec{start: 1, end: 3, step: 1, format: "%d", hasEnd: true} + r := buildSequenceValues(spec) + core.RequireTrue(t, r.OK) + core.AssertElementsMatch(t, []string{"1", "2", "3"}, r.Value.([]string)) +} + +func TestExecutorSequence_BuildSequenceValues_Ugly_DescendingAndFormat(t *core.T) { + spec := &sequenceSpec{start: 3, end: 1, step: -1, format: "host%02d", hasEnd: true} + r := buildSequenceValues(spec) + core.RequireTrue(t, r.OK) + core.AssertElementsMatch(t, []string{"host03", "host02", "host01"}, r.Value.([]string)) +} + +func TestExecutorSequence_FormatSequenceValue_Bad_EmptyOrBadFormatFallsBack(t *core.T) { + core.AssertEqual(t, "5", formatSequenceValue("", 5)) + // A format producing nothing usable falls back to plain decimal. + core.AssertEqual(t, "7", formatSequenceValue("%d", 7)) +} + +// --- parseSkipMissingValue --- + +func TestExecutorSequence_ParseSkipMissingValue_Good_BoolAndKeyword(t *core.T) { + core.AssertTrue(t, parseSkipMissingValue(true)) + core.AssertTrue(t, parseSkipMissingValue("skip_missing")) + core.AssertTrue(t, parseSkipMissingValue("skip_missing=true")) +} + +func TestExecutorSequence_ParseSkipMissingValue_Bad_FalseAndEmpty(t *core.T) { + core.AssertFalse(t, parseSkipMissingValue(false)) + core.AssertFalse(t, parseSkipMissingValue(" ")) + core.AssertFalse(t, parseSkipMissingValue(42)) +} + +func TestExecutorSequence_ParseSkipMissingValue_Ugly_Maps(t *core.T) { + core.AssertTrue(t, parseSkipMissingValue(map[string]any{"skip_missing": true})) + core.AssertTrue(t, parseSkipMissingValue(map[any]any{"skip_missing": true})) + core.AssertFalse(t, parseSkipMissingValue(map[any]any{42: true})) +} + +// --- parseSubelementsSpec --- + +func TestExecutorSequence_ParseSubelementsSpec_Good_AnySlice(t *core.T) { + parent, path, skip, ok := parseSubelementsSpec([]any{"users", "groups", true}) + core.AssertTrue(t, ok) + core.AssertEqual(t, "users", parent) + core.AssertEqual(t, "groups", path) + core.AssertTrue(t, skip) +} + +func TestExecutorSequence_ParseSubelementsSpec_Good_String(t *core.T) { + parent, path, skip, ok := parseSubelementsSpec("users groups") + core.AssertTrue(t, ok) + core.AssertEqual(t, "users", parent) + core.AssertEqual(t, "groups", path) + core.AssertFalse(t, skip) +} + +func TestExecutorSequence_ParseSubelementsSpec_Bad_TooShort(t *core.T) { + _, _, _, ok := parseSubelementsSpec([]any{"users"}) + core.AssertFalse(t, ok) + _, _, _, ok = parseSubelementsSpec("users") + core.AssertFalse(t, ok) + _, _, _, ok = parseSubelementsSpec(42) + core.AssertFalse(t, ok) +} + +func TestExecutorSequence_ParseSubelementsSpec_Ugly_StringSlice(t *core.T) { + parent, path, skip, ok := parseSubelementsSpec([]string{"users", "groups", "skip_missing"}) + core.AssertTrue(t, ok) + core.AssertEqual(t, "users", parent) + core.AssertEqual(t, "groups", path) + core.AssertTrue(t, skip) +} diff --git a/go/go.mod b/go/go.mod index 5e538f9..30bcb52 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,7 +3,7 @@ module dappco.re/go/ansible go 1.26.0 require ( - dappco.re/go v0.9.0 + dappco.re/go v0.10.4 dappco.re/go/io v0.9.0 dappco.re/go/log v0.9.0 golang.org/x/crypto v0.50.0 diff --git a/go/go.sum b/go/go.sum index 1b114f2..a6e8eab 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,9 @@ dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= +dappco.re/go v0.10.3/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.4 h1:vir5AK8AkHbTxhPUT0et6Tc0P8i/i+gLInM0LRLt1EU= +dappco.re/go v0.10.4/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0= dappco.re/go/io v0.9.0/go.mod h1:K5jWSLMdk0X9HqJ6b1I+8tKqcNpNWgpcUZi/fGm28Q8= dappco.re/go/log v0.9.0 h1:9+OiBUDyUNvqZZ++XemcjJPCgypr+Yf/1e5OP3X2nrk= diff --git a/go/modules_helpers_test.go b/go/modules_helpers_test.go new file mode 100644 index 0000000..3985f45 --- /dev/null +++ b/go/modules_helpers_test.go @@ -0,0 +1,121 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- getIntArg --- + +func TestModulesHelpers_GetIntArg_Good_NumericKinds(t *core.T) { + core.AssertEqual(t, 5, getIntArg(map[string]any{"n": 5}, "n", 0)) + core.AssertEqual(t, 9, getIntArg(map[string]any{"n": int64(9)}, "n", 0)) + core.AssertEqual(t, 3, getIntArg(map[string]any{"n": uint8(3)}, "n", 0)) +} + +func TestModulesHelpers_GetIntArg_Bad_MissingUsesDefault(t *core.T) { + core.AssertEqual(t, 42, getIntArg(map[string]any{}, "n", 42)) + core.AssertEqual(t, 7, getIntArg(map[string]any{"n": "not-int"}, "n", 7)) +} + +func TestModulesHelpers_GetIntArg_Ugly_StringParsed(t *core.T) { + core.AssertEqual(t, 12, getIntArg(map[string]any{"n": "12"}, "n", 0)) +} + +// --- normalizeStatusCodes --- + +func TestModulesHelpers_NormalizeStatusCodes_Good_ScalarAndSlice(t *core.T) { + core.AssertElementsMatch(t, []int{200}, normalizeStatusCodes(200, 0)) + core.AssertElementsMatch(t, []int{200, 201}, normalizeStatusCodes([]int{200, 201}, 0)) +} + +func TestModulesHelpers_NormalizeStatusCodes_Bad_NilUsesDefault(t *core.T) { + core.AssertElementsMatch(t, []int{200}, normalizeStatusCodes(nil, 200)) +} + +func TestModulesHelpers_NormalizeStatusCodes_Ugly_MixedAnySliceAndStrings(t *core.T) { + core.AssertElementsMatch(t, []int{200, 404}, normalizeStatusCodes([]any{200, "404"}, 0)) + core.AssertElementsMatch(t, []int{301, 302}, normalizeStatusCodes([]string{"301", "302"}, 0)) + core.AssertElementsMatch(t, []int{500}, normalizeStatusCodes("500", 0)) +} + +// --- normalizeStringList --- + +func TestModulesHelpers_NormalizeStringList_Good_CommaSplit(t *core.T) { + core.AssertElementsMatch(t, []string{"a", "b", "c"}, normalizeStringList("a, b ,c")) +} + +func TestModulesHelpers_NormalizeStringList_Bad_NilAndEmpty(t *core.T) { + core.AssertNil(t, normalizeStringList(nil)) + core.AssertNil(t, normalizeStringList("")) +} + +func TestModulesHelpers_NormalizeStringList_Ugly_SliceAndDefault(t *core.T) { + core.AssertElementsMatch(t, []string{"x", "y"}, normalizeStringList([]string{"x", " y ", ""})) + // A non-string scalar is stringified into a single-element list. + core.AssertElementsMatch(t, []string{"7"}, normalizeStringList(7)) +} + +// --- normalizeStringArgs --- + +func TestModulesHelpers_NormalizeStringArgs_Good_NoSplitOnComma(t *core.T) { + // Unlike normalizeStringList, a comma string is kept whole. + core.AssertElementsMatch(t, []string{"a,b"}, normalizeStringArgs("a,b")) +} + +func TestModulesHelpers_NormalizeStringArgs_Bad_NilAndBlank(t *core.T) { + core.AssertNil(t, normalizeStringArgs(nil)) + core.AssertNil(t, normalizeStringArgs(" ")) +} + +func TestModulesHelpers_NormalizeStringArgs_Ugly_AnySliceMixedTypes(t *core.T) { + core.AssertElementsMatch(t, []string{"a", "3"}, normalizeStringArgs([]any{"a", 3, nil})) +} + +// --- osFamilyFromReleaseID --- + +func TestModulesHelpers_OSFamilyFromReleaseID_Good_KnownFamilies(t *core.T) { + core.AssertEqual(t, "Debian", osFamilyFromReleaseID("ubuntu")) + core.AssertEqual(t, "RedHat", osFamilyFromReleaseID("CentOS")) + core.AssertEqual(t, "Archlinux", osFamilyFromReleaseID(" arch ")) + core.AssertEqual(t, "Alpine", osFamilyFromReleaseID("alpine")) +} + +func TestModulesHelpers_OSFamilyFromReleaseID_Bad_UnknownEmpty(t *core.T) { + core.AssertEqual(t, "", osFamilyFromReleaseID("plan9")) + core.AssertEqual(t, "", osFamilyFromReleaseID("")) +} + +// --- templateConditionEqual / Compare / Contains --- + +func TestModulesHelpers_TemplateConditionEqual_Good_NumericAndString(t *core.T) { + core.AssertTrue(t, templateConditionEqual(1, 1.0)) + core.AssertTrue(t, templateConditionEqual("x", "x")) + core.AssertFalse(t, templateConditionEqual("x", "y")) +} + +func TestModulesHelpers_TemplateConditionCompare_Good_NumericOrder(t *core.T) { + core.AssertEqual(t, -1, templateConditionCompare(1, 2)) + core.AssertEqual(t, 1, templateConditionCompare(3, 2)) + core.AssertEqual(t, 0, templateConditionCompare(2, 2)) +} + +func TestModulesHelpers_TemplateConditionCompare_Ugly_LexicalFallback(t *core.T) { + core.AssertEqual(t, -1, templateConditionCompare("alpha", "beta")) + core.AssertEqual(t, 1, templateConditionCompare("beta", "alpha")) + core.AssertEqual(t, 0, templateConditionCompare("same", "same")) +} + +func TestModulesHelpers_TemplateConditionContains_Good_StringAndSlice(t *core.T) { + core.AssertTrue(t, templateConditionContains("hello world", "world")) + core.AssertTrue(t, templateConditionContains([]any{"a", "b"}, "b")) + core.AssertFalse(t, templateConditionContains([]any{"a"}, "z")) +} + +func TestModulesHelpers_TemplateConditionContains_Bad_NilContainer(t *core.T) { + core.AssertFalse(t, templateConditionContains(nil, "x")) +} + +func TestModulesHelpers_TemplateConditionContains_Ugly_MapKeys(t *core.T) { + core.AssertTrue(t, templateConditionContains(map[string]any{"key": 1}, "key")) + core.AssertFalse(t, templateConditionContains(map[string]any{"key": 1}, "missing")) +} diff --git a/go/modules_shell_test.go b/go/modules_shell_test.go new file mode 100644 index 0000000..1005195 --- /dev/null +++ b/go/modules_shell_test.go @@ -0,0 +1,110 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- shellQuote --- + +func TestModulesShell_ShellQuote_Good_WrapsAndEscapes(t *core.T) { + core.AssertEqual(t, "'plain'", shellQuote("plain")) + core.AssertEqual(t, "'it'\\''s'", shellQuote("it's")) +} + +// --- wrapLocalBecomeCommand --- + +func TestModulesShell_WrapLocalBecomeCommand_Good_DefaultRootNoPassword(t *core.T) { + got := wrapLocalBecomeCommand("ls -l", "", "") + core.AssertContains(t, got, "sudo -n -u root bash -lc") + core.AssertContains(t, got, "ls -l") +} + +func TestModulesShell_WrapLocalBecomeCommand_Ugly_PasswordAndQuoteEscape(t *core.T) { + got := wrapLocalBecomeCommand("echo 'hi'", "deploy", "secret") + core.AssertContains(t, got, "sudo -S -u deploy bash -lc") + // The embedded single-quote is escaped for the wrapping shell. + core.AssertContains(t, got, "'\\''") +} + +// --- sedExactLinePattern --- + +func TestModulesShell_SedExactLinePattern_Good_EscapesRegexAndPipe(t *core.T) { + got := sedExactLinePattern("a.b|c") + // QuoteMeta escapes the dot; the pipe is additionally backslash-escaped. + core.AssertContains(t, got, "a\\.b") + core.AssertContains(t, got, "\\|c") +} + +// --- isAuthorizedKeyType / authorizedKeyBase --- + +func TestModulesShell_IsAuthorizedKeyType_Good_KnownPrefixes(t *core.T) { + core.AssertTrue(t, isAuthorizedKeyType("ssh-rsa")) + core.AssertTrue(t, isAuthorizedKeyType("ecdsa-sha2-nistp256")) + core.AssertTrue(t, isAuthorizedKeyType("sk-ssh-ed25519@openssh.com")) + core.AssertFalse(t, isAuthorizedKeyType("comment")) +} + +func TestModulesShell_AuthorizedKeyBase_Good_TypeAndKey(t *core.T) { + core.AssertEqual(t, "ssh-rsa AAAAB3", authorizedKeyBase("ssh-rsa AAAAB3 user@host")) +} + +func TestModulesShell_AuthorizedKeyBase_Bad_EmptyAndNoType(t *core.T) { + core.AssertEqual(t, "", authorizedKeyBase("")) + // A line without a recognised key-type returns the (trimmed) line as-is. + core.AssertEqual(t, "just a comment line", authorizedKeyBase(" just a comment line ")) +} + +func TestModulesShell_AuthorizedKeyBase_Ugly_TypeWithoutKey(t *core.T) { + core.AssertEqual(t, "ssh-rsa", authorizedKeyBase("ssh-rsa")) +} + +// --- rewriteAuthorizedKeyContent --- + +func TestModulesShell_RewriteAuthorizedKeyContent_Good_AppendsNewKey(t *core.T) { + content := "ssh-rsa AAAA existing@host\n" + line := "ssh-ed25519 BBBB new@host" + out, changed := rewriteAuthorizedKeyContent(content, "", line) + core.AssertTrue(t, changed) + core.AssertContains(t, out, "ssh-rsa AAAA existing@host") + core.AssertContains(t, out, line) +} + +func TestModulesShell_RewriteAuthorizedKeyContent_Bad_AlreadyPresentNoChange(t *core.T) { + line := "ssh-rsa AAAA user@host" + out, changed := rewriteAuthorizedKeyContent(line+"\n", "", line) + core.AssertFalse(t, changed) + core.AssertEqual(t, line+"\n", out) +} + +func TestModulesShell_RewriteAuthorizedKeyContent_Ugly_RemovalByEmptyLine(t *core.T) { + // Empty target line with a matching base removes that key entirely. + content := "ssh-rsa AAAA user@host\n" + out, changed := rewriteAuthorizedKeyContent(content, "ssh-rsa AAAA", "") + core.AssertTrue(t, changed) + core.AssertEqual(t, "", out) +} + +// --- resolveSerialBatchSizes / resolveSerialBatchSize --- + +func TestModulesShell_ResolveSerialBatchSizes_Good_IntAndNil(t *core.T) { + core.AssertElementsMatch(t, []int{10}, resolveSerialBatchSizes(nil, 10)) + core.AssertElementsMatch(t, []int{3}, resolveSerialBatchSizes(3, 10)) +} + +func TestModulesShell_ResolveSerialBatchSizes_Bad_NonPositiveTotal(t *core.T) { + core.AssertNil(t, resolveSerialBatchSizes(5, 0)) +} + +func TestModulesShell_ResolveSerialBatchSizes_Ugly_PercentAndSlice(t *core.T) { + // 30% of 10 hosts, rounded up. + core.AssertElementsMatch(t, []int{3}, resolveSerialBatchSizes("30%", 10)) + // A mixed list of absolute and percentage batches. + core.AssertElementsMatch(t, []int{1, 5}, resolveSerialBatchSizes([]any{1, "50%"}, 10)) + // Empty string falls back to the whole batch. + core.AssertElementsMatch(t, []int{10}, resolveSerialBatchSizes("", 10)) +} + +func TestModulesShell_ResolveSerialBatchSize_Good_FirstSize(t *core.T) { + core.AssertEqual(t, 1, resolveSerialBatchSize([]any{1, "50%"}, 10)) + core.AssertEqual(t, 10, resolveSerialBatchSize(nil, 10)) +} diff --git a/go/modules_uri_test.go b/go/modules_uri_test.go new file mode 100644 index 0000000..01157ef --- /dev/null +++ b/go/modules_uri_test.go @@ -0,0 +1,100 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- multipartFieldValues --- + +func TestModulesURI_MultipartFieldValues_Good_ScalarAndSlice(t *core.T) { + core.AssertElementsMatch(t, []string{"k=v"}, multipartFieldValues("k", "v")) + core.AssertElementsMatch(t, []string{"k="}, multipartFieldValues("k", nil)) + core.AssertElementsMatch(t, []string{"k=a", "k=b"}, multipartFieldValues("k", []string{"a", "b"})) +} + +func TestModulesURI_MultipartFieldValues_Ugly_AnySliceAndOther(t *core.T) { + core.AssertElementsMatch(t, []string{"n=1", "n=2"}, multipartFieldValues("n", []any{1, 2})) + core.AssertElementsMatch(t, []string{"flag=true"}, multipartFieldValues("flag", true)) +} + +// --- multipartBodyFields --- + +func TestModulesURI_MultipartBodyFields_Good_MapSorted(t *core.T) { + // Keys are emitted in sorted order. + got := multipartBodyFields(map[string]any{"b": "2", "a": "1"}) + core.AssertEqual(t, 2, len(got)) + core.AssertEqual(t, "a=1", got[0]) + core.AssertEqual(t, "b=2", got[1]) +} + +func TestModulesURI_MultipartBodyFields_Bad_EmptyStringNil(t *core.T) { + core.AssertNil(t, multipartBodyFields("")) +} + +func TestModulesURI_MultipartBodyFields_Ugly_AnySliceOfPairs(t *core.T) { + got := multipartBodyFields([]any{ + map[string]any{"key": "name", "value": "nginx"}, + map[string]any{"name": "state", "value": "present"}, + }) + core.AssertElementsMatch(t, []string{"name=nginx", "state=present"}, got) +} + +// --- appendFormValue / renderURIBodyFormEncoded --- + +func TestModulesURI_RenderURIBodyFormEncoded_Good_SortedMap(t *core.T) { + // url.Values.Encode sorts keys, so output is deterministic. + got := renderURIBodyFormEncoded(map[string]any{"name": "nginx", "state": "present"}) + core.AssertEqual(t, "name=nginx&state=present", got) +} + +func TestModulesURI_RenderURIBodyFormEncoded_Bad_StringPassthrough(t *core.T) { + core.AssertEqual(t, "already=encoded", renderURIBodyFormEncoded("already=encoded")) +} + +func TestModulesURI_RenderURIBodyFormEncoded_Ugly_SliceValuesAndNil(t *core.T) { + got := renderURIBodyFormEncoded(map[string]any{"tag": []string{"a", "b"}, "empty": nil}) + core.AssertContains(t, got, "tag=a") + core.AssertContains(t, got, "tag=b") + core.AssertContains(t, got, "empty=") +} + +// --- isHexDigest --- + +func TestModulesURI_IsHexDigest_Good_LowerHex(t *core.T) { + core.AssertTrue(t, isHexDigest("deadbeef0123")) +} + +func TestModulesURI_IsHexDigest_Bad_EmptyAndNonHex(t *core.T) { + core.AssertFalse(t, isHexDigest("")) + core.AssertFalse(t, isHexDigest("xyz")) + core.AssertFalse(t, isHexDigest("DEADBEEF")) // uppercase not accepted +} + +// --- parseGetURLChecksumFile --- + +func TestModulesURI_ParseGetURLChecksumFile_Good_MatchesFilename(t *core.T) { + content := "abc123 other.tar.gz\ndef456 app.tar.gz\n" + r := parseGetURLChecksumFile(content, "/tmp/app.tar.gz", "sha256") + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "def456", r.Value.(string)) +} + +func TestModulesURI_ParseGetURLChecksumFile_Good_SingleFieldDigest(t *core.T) { + r := parseGetURLChecksumFile("abc123\n", "/tmp/app.tar.gz", "sha256") + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "abc123", r.Value.(string)) +} + +func TestModulesURI_ParseGetURLChecksumFile_Bad_NoDigest(t *core.T) { + r := parseGetURLChecksumFile("not a checksum file\n", "/tmp/app.tar.gz", "sha256") + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "could not parse checksum file") +} + +func TestModulesURI_ParseGetURLChecksumFile_Ugly_FallsBackToFirstDigest(t *core.T) { + // No filename match, but a bare digest line exists -> first digest wins. + content := "deadbeef unrelated.bin\n" + r := parseGetURLChecksumFile(content, "/tmp/app.tar.gz", "sha256") + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "deadbeef", r.Value.(string)) +} diff --git a/go/parser_helpers_test.go b/go/parser_helpers_test.go new file mode 100644 index 0000000..61318da --- /dev/null +++ b/go/parser_helpers_test.go @@ -0,0 +1,106 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- parseActionSpec / parseActionSpecString --- + +func TestParserHelpers_ParseActionSpec_Good_StringWithArgs(t *core.T) { + module, args := parseActionSpec("apt name=nginx state=present") + core.AssertEqual(t, "apt", module) + core.AssertEqual(t, "nginx", args["name"]) + core.AssertEqual(t, "present", args["state"]) +} + +func TestParserHelpers_ParseActionSpec_Good_MapForm(t *core.T) { + module, args := parseActionSpec(map[string]any{ + "module": "service", + "name": "nginx", + "state": "started", + }) + core.AssertEqual(t, "service", module) + core.AssertEqual(t, "nginx", args["name"]) + core.AssertEqual(t, "started", args["state"]) +} + +func TestParserHelpers_ParseActionSpec_Bad_EmptyAndNoModule(t *core.T) { + module, args := parseActionSpec("") + core.AssertEqual(t, "", module) + core.AssertNil(t, args) + + module, _ = parseActionSpec(map[string]any{"name": "nginx"}) + core.AssertEqual(t, "", module) +} + +func TestParserHelpers_ParseActionSpec_Ugly_FreeFormRawParams(t *core.T) { + module, args := parseActionSpec("command echo hello world") + core.AssertEqual(t, "command", module) + core.AssertEqual(t, "echo hello world", args["_raw_params"]) +} + +func TestParserHelpers_ParseActionSpec_Ugly_ModuleOnly(t *core.T) { + module, args := parseActionSpec("ping") + core.AssertEqual(t, "ping", module) + core.AssertNil(t, args) +} + +// --- nestedLoopGroups / nestedLoopItems --- + +func TestParserHelpers_NestedLoopGroups_Good_NestedSlices(t *core.T) { + groups, ok := nestedLoopGroups([]any{[]any{"a", "b"}, []any{1, 2}}) + core.AssertTrue(t, ok) + core.AssertLen(t, groups, 2) + core.AssertLen(t, groups[0], 2) +} + +func TestParserHelpers_NestedLoopGroups_Bad_EmptyGroupFails(t *core.T) { + _, ok := nestedLoopGroups([]any{[]any{}}) + core.AssertFalse(t, ok) + _, ok = nestedLoopGroups("") + core.AssertFalse(t, ok) +} + +func TestParserHelpers_NestedLoopGroups_Ugly_StringAndScalar(t *core.T) { + groups, ok := nestedLoopGroups("solo") + core.AssertTrue(t, ok) + core.AssertLen(t, groups, 1) + core.AssertEqual(t, "solo", groups[0][0]) + + groups, ok = nestedLoopGroups([]string{"x", "y"}) + core.AssertTrue(t, ok) + core.AssertLen(t, groups, 1) + core.AssertLen(t, groups[0], 2) +} + +func TestParserHelpers_NestedLoopItems_Ugly_KindCoverage(t *core.T) { + core.AssertNil(t, nestedLoopItems(nil)) + core.AssertLen(t, nestedLoopItems([]any{1, 2}), 2) + core.AssertLen(t, nestedLoopItems([]string{"a"}), 1) + core.AssertLen(t, nestedLoopItems(42), 1) +} + +// --- togetherLoopGroups --- + +func TestParserHelpers_TogetherLoopGroups_Good_ParallelSlices(t *core.T) { + groups, ok := togetherLoopGroups([]any{[]any{"a", "b"}, []any{"1", "2"}}) + core.AssertTrue(t, ok) + core.AssertLen(t, groups, 2) +} + +func TestParserHelpers_TogetherLoopGroups_Bad_EmptyAndBlankString(t *core.T) { + _, ok := togetherLoopGroups([]any{[]any{}}) + core.AssertFalse(t, ok) + _, ok = togetherLoopGroups("") + core.AssertFalse(t, ok) +} + +func TestParserHelpers_TogetherLoopGroups_Ugly_StringSliceAndScalar(t *core.T) { + groups, ok := togetherLoopGroups([]string{"x", "y"}) + core.AssertTrue(t, ok) + core.AssertLen(t, groups[0], 2) + + groups, ok = togetherLoopGroups(99) + core.AssertTrue(t, ok) + core.AssertEqual(t, 99, groups[0][0]) +} diff --git a/go/service.go b/go/service.go new file mode 100644 index 0000000..2068f6d --- /dev/null +++ b/go/service.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Service registration for the ansible package — exposes the canonical +// `NewService(opts)` + `Register(c)` shape per Mantis #1336, holding a +// pre-wired Parser + Executor pair that consumers can use through a +// typed handle: +// +// c, _ := core.New( +// core.WithService(ansible.NewService(ansible.Options{ +// BasePath: "/srv/playbooks", +// })), +// ) +// svc := core.MustServiceFor[*ansible.Service](c, "ansible") +// r := svc.Parser().ParsePlaybook("site.yml") +// +// The package-level types (Parser, Executor) remain the source of truth — +// Service is a thin Core-bound handle that pre-constructs them with the +// supplied options. Consumers that need bespoke wiring can keep using +// NewParser / NewExecutor directly. +// +// The CLI command surface lives in the sibling `ansiblecmd` package +// (cmd/ansible) and registers `ansible` + `ansible/test` commands — +// independent of this service registration, so consumers can wire either +// or both. + +package ansible + +import ( + core "dappco.re/go" +) + +// Options configures the ansible service. All fields are optional — +// an empty Options yields a Service with a Parser rooted at the +// process working directory and no pre-built Executor (callers wire +// transports per-host through NewExecutor). +type Options struct { + // BasePath is the playbook root the pre-wired Parser resolves + // relative paths against. Empty → Parser uses the process working + // directory via NewParser(""). + BasePath string +} + +// Service is the registerable handle for the ansible package — embeds +// *core.ServiceRuntime[Options] for typed options access and exposes a +// pre-wired *Parser ready for playbook / inventory / role parsing. +// +// Usage example: `svc := core.MustServiceFor[*ansible.Service](c, "ansible"); r := svc.Parser().ParsePlaybook("site.yml")` +type Service struct { + *core.ServiceRuntime[Options] + // parser is the pre-built Parser configured with Options.BasePath. + // Access through Service.Parser(). + parser *Parser +} + +// NewService returns a factory that constructs a *Service with the +// supplied options and registers it under "ansible" via +// core.WithService. +// +// core.WithService(ansible.NewService(ansible.Options{BasePath: "/srv/playbooks"})) +// +// The returned factory always succeeds — Parser construction is +// in-memory and never errors. Callers that need bespoke Parser +// configuration (custom Medium, etc.) can mutate svc.Parser() after +// registration or bypass the Service entirely and use NewParser. +func NewService(opts Options) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Ok(&Service{ + ServiceRuntime: core.NewServiceRuntime(c, opts), + parser: NewParser(opts.BasePath), + }) + } +} + +// Register wires the ansible service into the Core with default Options +// — the imperative-style alternative to NewService for consumers that +// don't use the WithService factory pattern. +// +// c := core.New() +// if r := ansible.Register(c); !r.OK { return r } +// svc := core.MustServiceFor[*ansible.Service](c, "ansible") +func Register(c *core.Core) core.Result { + return NewService(Options{})(c) +} + +// Parser returns the service's pre-built *Parser. Callers that need a +// fresh Parser with bespoke configuration should construct one via +// NewParser instead of mutating the shared one. +// +// p := svc.Parser() +// r := p.ParsePlaybook("site.yml") +func (s *Service) Parser() *Parser { + if s == nil { + return nil + } + return s.parser +} diff --git a/go/service_example_test.go b/go/service_example_test.go new file mode 100644 index 0000000..b0f3a6d --- /dev/null +++ b/go/service_example_test.go @@ -0,0 +1,26 @@ +package ansible + +import ( + core "dappco.re/go" +) + +func ExampleNewService() { + c := core.New(core.WithService(NewService(Options{BasePath: "/srv/playbooks"}))) + svc := core.MustServiceFor[*Service](c, "ansible") + core.Println(svc.Parser() != nil) + // Output: true +} + +func ExampleRegister() { + c := core.New(core.WithService(Register)) + r := c.Service("ansible") + core.Println(r.OK) + // Output: true +} + +func ExampleService_Parser() { + c := core.New(core.WithService(NewService(Options{}))) + svc := core.MustServiceFor[*Service](c, "ansible") + core.Println(svc.Parser() != nil) + // Output: true +} diff --git a/go/service_test.go b/go/service_test.go new file mode 100644 index 0000000..e49a66e --- /dev/null +++ b/go/service_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ansible + +import ( + "testing" + + core "dappco.re/go" +) + +// TestNewService_DefaultOptions_RegistersWithParser verifies the canonical +// factory builds a Service with a pre-wired Parser when called with empty +// Options (Parser falls back to the process working directory). +func TestNewService_DefaultOptions_RegistersWithParser(t *testing.T) { + c := core.New(core.WithService(NewService(Options{}))) + r := c.Service("ansible") + if !r.OK { + t.Fatal("ansible service not registered via NewService") + } + svc := r.Value.(*Service) + if svc.Parser() == nil { + t.Fatal("expected pre-wired Parser with default Options") + } +} + +// TestNewService_BasePath_ConstructsParserAtRoot verifies the BasePath +// option flows through to the Parser construction. +func TestNewService_BasePath_ConstructsParserAtRoot(t *testing.T) { + dir := t.TempDir() + c := core.New(core.WithService(NewService(Options{BasePath: dir}))) + r := c.Service("ansible") + if !r.OK { + t.Fatal("ansible service not registered") + } + svc := r.Value.(*Service) + if svc.Parser() == nil { + t.Fatal("expected pre-wired Parser with custom BasePath") + } +} + +// TestRegister_DefaultsRegistersWithParser verifies the imperative-style +// Register(c) shorthand registers the Service with default Options. +func TestRegister_DefaultsRegistersWithParser(t *testing.T) { + c := core.New(core.WithService(Register)) + r := c.Service("ansible") + if !r.OK { + t.Fatalf("ansible service not registered via Register, got %#v", r.Value) + } + svc := r.Value.(*Service) + if svc.Parser() == nil { + t.Fatal("expected pre-wired Parser via Register") + } +} + +// TestService_NilReceiver_ParserReturnsNil verifies the nil-receiver guard +// on Parser() returns nil rather than panicking. +func TestService_NilReceiver_ParserReturnsNil(t *testing.T) { + var svc *Service + if p := svc.Parser(); p != nil { + t.Fatalf("expected nil Parser from nil receiver, got %#v", p) + } +} diff --git a/go/template_features_test.go b/go/template_features_test.go new file mode 100644 index 0000000..24422ab --- /dev/null +++ b/go/template_features_test.go @@ -0,0 +1,221 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- isEmptyTemplateValue --- + +func TestTemplateFeatures_IsEmptyTemplateValue_Good_EmptyKinds(t *core.T) { + core.AssertTrue(t, isEmptyTemplateValue(nil)) + core.AssertTrue(t, isEmptyTemplateValue("")) + core.AssertTrue(t, isEmptyTemplateValue([]any{})) + core.AssertTrue(t, isEmptyTemplateValue([]string{})) + core.AssertTrue(t, isEmptyTemplateValue(map[string]any{})) + core.AssertTrue(t, isEmptyTemplateValue(map[any]any{})) +} + +func TestTemplateFeatures_IsEmptyTemplateValue_Bad_NonEmptyKinds(t *core.T) { + core.AssertFalse(t, isEmptyTemplateValue("x")) + core.AssertFalse(t, isEmptyTemplateValue([]any{1})) + core.AssertFalse(t, isEmptyTemplateValue([]string{"a"})) + core.AssertFalse(t, isEmptyTemplateValue(map[string]any{"k": 1})) + core.AssertFalse(t, isEmptyTemplateValue(42)) +} + +func TestTemplateFeatures_IsEmptyTemplateValue_Ugly_UnresolvedAndReflectKinds(t *core.T) { + // An unresolved {{ ... }} placeholder counts as empty so default filters fire. + core.AssertTrue(t, isEmptyTemplateValue("{{ undefined_var }}")) + // Reflect path: an empty typed slice. + core.AssertTrue(t, isEmptyTemplateValue([]int{})) + core.AssertFalse(t, isEmptyTemplateValue([]int{1, 2})) + // Reflect path: an empty typed map. + core.AssertTrue(t, isEmptyTemplateValue(map[int]int{})) +} + +// --- templateBool --- + +func TestTemplateFeatures_TemplateBool_Good_TruthyStrings(t *core.T) { + core.AssertTrue(t, templateBool("true")) + core.AssertTrue(t, templateBool("YES")) + core.AssertTrue(t, templateBool(" 1 ")) + core.AssertTrue(t, templateBool(true)) +} + +func TestTemplateFeatures_TemplateBool_Bad_FalsyValues(t *core.T) { + core.AssertFalse(t, templateBool("false")) + core.AssertFalse(t, templateBool("no")) + core.AssertFalse(t, templateBool("")) + core.AssertFalse(t, templateBool(false)) + core.AssertFalse(t, templateBool(0)) +} + +func TestTemplateFeatures_TemplateBool_Ugly_NumericKindsAndFallback(t *core.T) { + core.AssertTrue(t, templateBool(int8(1))) + core.AssertTrue(t, templateBool(int64(-1))) + core.AssertTrue(t, templateBool(uint16(3))) + core.AssertTrue(t, templateBool(float32(0.5))) + core.AssertFalse(t, templateBool(float64(0))) + // Unknown type falls through to stringify-non-empty. + core.AssertTrue(t, templateBool([]any{1})) + core.AssertFalse(t, templateBool(nil)) +} + +// --- templateInt --- + +func TestTemplateFeatures_TemplateInt_Good_NumericAndString(t *core.T) { + n, ok := templateInt(7) + core.AssertTrue(t, ok) + core.AssertEqual(t, 7, n) + + n, ok = templateInt("42") + core.AssertTrue(t, ok) + core.AssertEqual(t, 42, n) + + n, ok = templateInt(int64(9)) + core.AssertTrue(t, ok) + core.AssertEqual(t, 9, n) +} + +func TestTemplateFeatures_TemplateInt_Bad_NonNumericString(t *core.T) { + n, ok := templateInt("not-a-number") + core.AssertFalse(t, ok) + core.AssertEqual(t, 0, n) + + n, ok = templateInt(nil) + core.AssertFalse(t, ok) + core.AssertEqual(t, 0, n) +} + +func TestTemplateFeatures_TemplateInt_Ugly_FloatStringTruncation(t *core.T) { + // A float-looking string flows through templateFloat and truncates. + n, ok := templateInt("3.9") + core.AssertTrue(t, ok) + core.AssertEqual(t, 3, n) + + n, ok = templateInt(float64(5.7)) + core.AssertTrue(t, ok) + core.AssertEqual(t, 5, n) + + n, ok = templateInt(uint8(255)) + core.AssertTrue(t, ok) + core.AssertEqual(t, 255, n) +} + +// --- templateFloat --- + +func TestTemplateFeatures_TemplateFloat_Good_NumericAndString(t *core.T) { + f, ok := templateFloat(2.5) + core.AssertTrue(t, ok) + core.AssertEqual(t, 2.5, f) + + f, ok = templateFloat("1.25") + core.AssertTrue(t, ok) + core.AssertEqual(t, 1.25, f) + + f, ok = templateFloat(int32(4)) + core.AssertTrue(t, ok) + core.AssertEqual(t, float64(4), f) +} + +func TestTemplateFeatures_TemplateFloat_Bad_NonNumeric(t *core.T) { + f, ok := templateFloat("xyz") + core.AssertFalse(t, ok) + core.AssertEqual(t, float64(0), f) + + f, ok = templateFloat([]any{1}) + core.AssertFalse(t, ok) + core.AssertEqual(t, float64(0), f) +} + +func TestTemplateFeatures_TemplateFloat_Ugly_UnsignedAndWhitespaceString(t *core.T) { + f, ok := templateFloat(uint64(8)) + core.AssertTrue(t, ok) + core.AssertEqual(t, float64(8), f) + + f, ok = templateFloat(" 6.0 ") + core.AssertTrue(t, ok) + core.AssertEqual(t, float64(6), f) +} + +// --- templateLength --- + +func TestTemplateFeatures_TemplateLength_Good_StringAndSlices(t *core.T) { + core.AssertEqual(t, 3, templateLength("abc")) + core.AssertEqual(t, 2, templateLength([]any{1, 2})) + core.AssertEqual(t, 1, templateLength([]string{"x"})) + core.AssertEqual(t, 2, templateLength(map[string]any{"a": 1, "b": 2})) +} + +func TestTemplateFeatures_TemplateLength_Bad_NilAndUnsupported(t *core.T) { + core.AssertEqual(t, 0, templateLength(nil)) + // A bare int has no native length and stringifies to "5" -> length 1. + core.AssertEqual(t, 1, templateLength(5)) +} + +func TestTemplateFeatures_TemplateLength_Ugly_ReflectKindsAndMapAny(t *core.T) { + core.AssertEqual(t, 1, templateLength(map[any]any{"k": "v"})) + // Reflect path: a typed slice. + core.AssertEqual(t, 3, templateLength([]int{1, 2, 3})) + // Reflect path: a typed array. + core.AssertEqual(t, 2, templateLength([2]int{0, 0})) +} + +// --- templateValueGreater --- + +func TestTemplateFeatures_TemplateValueGreater_Good_NumericCompare(t *core.T) { + core.AssertTrue(t, templateValueGreater(5, 3, true)) + core.AssertFalse(t, templateValueGreater(5, 3, false)) + core.AssertTrue(t, templateValueGreater(2, 9, false)) +} + +func TestTemplateFeatures_TemplateValueGreater_Bad_StringFallback(t *core.T) { + // Non-numeric values fall back to lexical comparison. + core.AssertTrue(t, templateValueGreater("beta", "alpha", true)) + core.AssertFalse(t, templateValueGreater("beta", "alpha", false)) +} + +func TestTemplateFeatures_TemplateValueGreater_Ugly_MixedNumericString(t *core.T) { + // One side non-numeric -> lexical path on both stringified forms. + core.AssertTrue(t, templateValueGreater("9", "x", false)) +} + +// --- templateMinMax --- + +func TestTemplateFeatures_TemplateMinMax_Good_MinAndMax(t *core.T) { + got, ok := templateMinMax([]any{3, 1, 2}, false) + core.AssertTrue(t, ok) + core.AssertEqual(t, 1, got) + + got, ok = templateMinMax([]any{3, 1, 2}, true) + core.AssertTrue(t, ok) + core.AssertEqual(t, 3, got) +} + +func TestTemplateFeatures_TemplateMinMax_Bad_EmptyOrNonSlice(t *core.T) { + _, ok := templateMinMax([]any{}, true) + core.AssertFalse(t, ok) + + _, ok = templateMinMax(42, true) + core.AssertFalse(t, ok) +} + +func TestTemplateFeatures_TemplateMinMax_Ugly_StringElements(t *core.T) { + got, ok := templateMinMax([]any{"banana", "apple", "cherry"}, false) + core.AssertTrue(t, ok) + core.AssertEqual(t, "apple", got) +} + +// --- templateStringify --- + +func TestTemplateFeatures_TemplateStringify_Good_Primitives(t *core.T) { + core.AssertEqual(t, "", templateStringify(nil)) + core.AssertEqual(t, "hello", templateStringify("hello")) +} + +func TestTemplateFeatures_TemplateStringify_Ugly_CollectionsAndOther(t *core.T) { + core.AssertNotEmpty(t, templateStringify([]string{"a", "b"})) + core.AssertNotEmpty(t, templateStringify([]any{1, 2})) + core.AssertNotEmpty(t, templateStringify(map[string]any{"k": 1})) + core.AssertNotEmpty(t, templateStringify(3.14)) +} diff --git a/go/types_merge_test.go b/go/types_merge_test.go new file mode 100644 index 0000000..7464898 --- /dev/null +++ b/go/types_merge_test.go @@ -0,0 +1,50 @@ +package ansible + +import ( + core "dappco.re/go" +) + +// --- mergeInventoryGroups --- + +func TestTypesMerge_MergeInventoryGroups_Good_MergesAllSections(t *core.T) { + dst := &InventoryGroup{ + Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1"}}, + Vars: map[string]any{"env": "prod"}, + } + src := &InventoryGroup{ + Hosts: map[string]*Host{"web2": {AnsibleHost: "10.0.0.2"}}, + Children: map[string]*InventoryGroup{"db": {Vars: map[string]any{"role": "db"}}}, + Vars: map[string]any{"region": "eu"}, + } + + mergeInventoryGroups(dst, src) + + core.AssertEqual(t, "10.0.0.1", dst.Hosts["web1"].AnsibleHost) + core.AssertEqual(t, "10.0.0.2", dst.Hosts["web2"].AnsibleHost) + core.AssertEqual(t, "prod", dst.Vars["env"]) + core.AssertEqual(t, "eu", dst.Vars["region"]) + core.AssertNotNil(t, dst.Children["db"]) +} + +func TestTypesMerge_MergeInventoryGroups_Bad_NilOperandsNoPanic(t *core.T) { + dst := &InventoryGroup{} + // Neither nil operand should mutate or panic. + core.AssertNotPanics(t, func() { mergeInventoryGroups(nil, dst) }) + core.AssertNotPanics(t, func() { mergeInventoryGroups(dst, nil) }) +} + +func TestTypesMerge_MergeInventoryGroups_Ugly_EmptyDstMapsAllocated(t *core.T) { + // dst starts with all nil maps; merge must allocate them from src. + dst := &InventoryGroup{} + src := &InventoryGroup{ + Hosts: map[string]*Host{"h": {AnsibleHost: "1.1.1.1"}}, + Children: map[string]*InventoryGroup{"c": {}}, + Vars: map[string]any{"k": "v"}, + } + + mergeInventoryGroups(dst, src) + + core.AssertEqual(t, "1.1.1.1", dst.Hosts["h"].AnsibleHost) + core.AssertNotNil(t, dst.Children["c"]) + core.AssertEqual(t, "v", dst.Vars["k"]) +}