summaryrefslogtreecommitdiff
path: root/plugins/modules/vyos_banner.py
blob: 4824629be8ffeef335844200113a7370a0ece709 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
#!/usr/bin/python

from ansible.module_utils.basic import AnsibleModule
from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule


DOCUMENTATION = r"""
---
module: vyos_banner
short_description: Manage login banners on VyOS devices using REST API
description:
  - Configure pre-login and post-login banners on VyOS devices via REST API.
  - Supports idempotent configuration using structured data.
  - Multiline banner text is supported via YAML block scalars.
  - Uses REST API (C(connection=httpapi)) instead of CLI.
  - VyOS stores multiline banners as a single string with literal C(\\n) separators.
version_added: "1.0.0"
author:
  - Your Name (@yourhandle)

options:
  config:
    description:
      - Banner configuration.
    type: dict
    required: true
    suboptions:
      banner:
        description:
          - Banner type to configure.
        type: str
        required: true
        choices:
          - pre-login
          - post-login
      text:
        description:
          - Banner text. Supports multiline strings via YAML block scalar (|).
          - Internally, real newlines are converted to literal C(\\n) as VyOS expects.
        type: str

  state:
    description:
      - Desired state of the configuration.
    type: str
    default: merged
    choices:
      - merged
      - replaced
      - overridden
      - deleted
      - gathered

notes:
  - This module requires C(ansible_connection=httpapi).
  - Banner text comparison is whitespace-normalized for idempotency.
  - VyOS stores banner text as a leaf string value with literal C(\\n) for newlines.
  - For C(merged), C(replaced), and C(overridden) states, the behaviour is identical
    for this single-leaf resource — the banner value is set to the desired text.
"""

EXAMPLES = r"""
- name: Configure single-line pre-login banner
  vyos.rest.vyos_banner:
    config:
      banner: pre-login
      text: "Unauthorized access is prohibited"
    state: merged

- name: Configure multiline post-login banner
  vyos.rest.vyos_banner:
    config:
      banner: post-login
      text: |
        Welcome to VyOS
        Authorized users only
        Disconnect if you are not authorized
    state: merged

- name: Replace post-login banner
  vyos.rest.vyos_banner:
    config:
      banner: post-login
      text: "Welcome to VyOS"
    state: replaced

- name: Remove pre-login banner
  vyos.rest.vyos_banner:
    config:
      banner: pre-login
    state: deleted

- name: Gather banner configuration
  vyos.rest.vyos_banner:
    config:
      banner: pre-login
    state: gathered
"""

RETURN = r"""
before:
  description: Configuration before changes (text uses real newlines).
  returned: always
  type: dict
  sample:
    banner: pre-login
    text: "Old banner text"

after:
  description: Configuration after changes (text uses real newlines).
  returned: when changed
  type: dict
  sample:
    banner: pre-login
    text: "New banner text"

commands:
  description: List of API command dicts sent to the device.
  returned: when changes are required
  type: list
  sample:
    - op: set
      path: ["system", "login", "banner", "pre-login"]
      value: "New banner text"

gathered:
  description: Current device configuration (text uses real newlines).
  returned: when state is gathered
  type: dict
  sample:
    banner: pre-login
    text: "Current banner text"

response:
  description: Raw response from VyOS REST API.
  returned: when changes are applied
  type: dict

saved:
  description: Result of save_config call after applying changes.
  returned: when changes are applied
  type: dict
"""


# ------------------------------------------------------------
# Text conversion helpers
#
# Internal canonical form: real Python newlines (\n), stripped.
#
# VyOS wire format: literal backslash-n (\\n) sequences within
# a single quoted string, e.g. 'line one\nline two\nline three'.
#
# Rule: convert FROM wire format on read, TO wire format on write,
#       always compare in canonical (internal) form.
# ------------------------------------------------------------


def normalize_text(text):
    """
    Normalize text to canonical internal form.

    - Converts to str if needed
    - Strips each line
    - Removes leading/trailing blank lines
    - Joins with real newlines
    - Returns None if result is empty
    """
    if text is None:
        return None
    lines = [line.strip() for line in text.strip().splitlines()]
    # Trim leading blank lines
    while lines and not lines[0]:
        lines.pop(0)
    # Trim trailing blank lines
    while lines and not lines[-1]:
        lines.pop()
    return "\n".join(lines) if lines else None


def text_from_api_value(raw):
    """
    Convert VyOS API/device value to canonical internal form.

    VyOS returns multiline banners with literal \\n sequences:
      'line one\\nline two\\nline three'

    Convert those to real newlines first, then normalize.
    """
    if raw is None:
        return None
    return normalize_text(raw.replace("\\n", "\n"))


def text_to_api_value(text):
    """
    Convert canonical internal form to VyOS wire format.

    Real newlines become literal \\n sequences as VyOS expects.
    Returns None if input normalizes to None.
    """
    normalized = normalize_text(text)
    if normalized is None:
        return None
    return normalized.replace("\n", "\\n")


# ------------------------------------------------------------
# Device interaction
# ------------------------------------------------------------


def get_running_config(vyos, banner):
    """
    Fetch current banner configuration from the device.

    The VyOS showConfig API returns banner text as a plain string value:
      {"pre-login": "test1"}
      {"post-login": "line one\\nline two\\nline three"}

    Returns:
      {"banner": <str>, "text": <normalized str or None>}
    """
    try:
        raw = vyos.get_config(["system", "login", "banner"])
    except Exception as e:
        if "Configuration under specified path is empty" in str(e):
            return {"banner": banner, "text": None}
        raise

    if not raw or not isinstance(raw, dict):
        return {"banner": banner, "text": None}

    val = raw.get(banner)

    if val is None:
        return {"banner": banner, "text": None}

    if isinstance(val, str):
        # Convert from wire format (literal \n) to internal form (real newlines)
        return {"banner": banner, "text": text_from_api_value(val)}

    # Unexpected type — surface clearly rather than silently swallowing
    raise ValueError(
        "Unexpected banner value type '{t}' returned by API (value={v!r}). "
        "Expected a plain string. Please file a bug.".format(
            t=type(val).__name__,
            v=val,
        ),
    )


def build_commands(want, have, state):
    """
    Build list of VyOS REST API command dicts.

    VyOS banner API payload structure:
      SET:    {"op": "set",    "path": ["system","login","banner","<type>"], "value": "<text>"}
      DELETE: {"op": "delete", "path": ["system","login","banner","<type>"]}

    The banner text is always a leaf VALUE on the path, never a path segment.

    All text comparison is done in canonical internal form (real newlines).
    Conversion to wire format (literal \\n) happens only when building the
    final API command value.
    """
    banner = want["banner"]

    # Canonical internal form for comparison
    want_text = normalize_text(want.get("text"))
    have_text = have.get("text")  # already in canonical form from get_running_config

    base_path = ["system", "login", "banner", banner]
    banner_exists = have_text is not None

    # --------------------------------------------------------
    # deleted: remove the banner node if it exists
    # --------------------------------------------------------
    if state == "deleted":
        if banner_exists:
            return [{"op": "delete", "path": base_path}]
        return []

    # --------------------------------------------------------
    # merged / replaced / overridden
    #
    # For this single leaf-value resource all three states are
    # semantically equivalent: ensure the desired value is set.
    # Idempotency: skip if the normalized value already matches.
    # --------------------------------------------------------
    if state in ("merged", "replaced", "overridden"):
        if want_text is None:
            # No text provided with a non-delete state — nothing to do
            return []

        if want_text == have_text:
            # Already correct — idempotent, no change needed
            return []

        # Convert to wire format only at the point of building the payload
        return [
            {
                "op": "set",
                "path": base_path,
                "value": text_to_api_value(want_text),
            },
        ]

    return []


# ------------------------------------------------------------
# Main
# ------------------------------------------------------------


def main():

    argument_spec = dict(
        config=dict(
            type="dict",
            required=True,
            options=dict(
                banner=dict(
                    type="str",
                    required=True,
                    choices=["pre-login", "post-login"],
                ),
                text=dict(type="str"),
            ),
        ),
        state=dict(
            default="merged",
            choices=[
                "merged",
                "replaced",
                "overridden",
                "deleted",
                "gathered",
            ],
        ),
    )

    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,
    )

    vyos = VyOSModule(module)

    state = module.params["state"]
    config = module.params.get("config") or {}
    banner = config.get("banner")

    if not banner:
        module.fail_json(msg="banner is required in config")

    # Fetch current device state (text in canonical internal form)
    have = get_running_config(vyos, banner)

    # --------------------------------------------------------
    # gathered: return current state, no changes
    # --------------------------------------------------------
    if state == "gathered":
        module.exit_json(changed=False, gathered=have)

    want = {
        "banner": banner,
        "text": config.get("text"),
    }

    commands = build_commands(want, have, state)

    # --------------------------------------------------------
    # check mode: report what would change, make no API calls
    # --------------------------------------------------------
    if module.check_mode:
        module.exit_json(
            changed=bool(commands),
            commands=commands,
            before=have,
        )

    # --------------------------------------------------------
    # apply changes
    # --------------------------------------------------------
    if commands:
        response = vyos.apply_commands(commands)
        saved = vyos.save_config()

        module.exit_json(
            changed=True,
            before=have,
            # Report after-state in canonical form (real newlines, human-readable)
            after=want,
            # after={
            #     "banner": banner,
            #     "text": normalize_text(want["text"]),
            # },
            commands=commands,
            saved=saved,
            response=response,
        )

    # --------------------------------------------------------
    # no changes needed
    # --------------------------------------------------------
    module.exit_json(
        changed=False,
        before=have,
        after=have,
    )


if __name__ == "__main__":
    main()