From d93a13eeee237783b2c181b4ac20e1efc3517e98 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:56:55 +0200 Subject: [PATCH] feat: add YAML validation for cloud-init scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ValidCloudInitYaml validation rule to ensure cloud-init scripts are properly formatted before saving. The validator supports: - Cloud-config YAML (with or without #cloud-config header) - Bash scripts (starting with #!) - Empty/null values (optional field) Uses Symfony YAML parser to validate YAML syntax and provides detailed error messages when validation fails. Added comprehensive unit tests covering: - Valid cloud-config with/without header - Valid bash scripts - Invalid YAML syntax detection - Complex multi-section cloud-config Applied validation to: - ByHetzner component (server creation) - CloudInitScriptForm component (script management) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Livewire/Security/CloudInitScriptForm.php | 2 +- app/Livewire/Server/New/ByHetzner.php | 2 +- app/Rules/ValidCloudInitYaml.php | 55 ++++++ .../security/cloud-init-script-form.blade.php | 2 +- tests/Unit/Rules/ValidCloudInitYamlTest.php | 174 ++++++++++++++++++ 5 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 app/Rules/ValidCloudInitYaml.php create mode 100644 tests/Unit/Rules/ValidCloudInitYamlTest.php diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php index ff670cd4f..33beff334 100644 --- a/app/Livewire/Security/CloudInitScriptForm.php +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -36,7 +36,7 @@ class CloudInitScriptForm extends Component { return [ 'name' => 'required|string|max:255', - 'script' => 'required|string', + 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml], ]; } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 7d828b12e..abbe4c379 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -157,7 +157,7 @@ class ByHetzner extends Component 'selectedHetznerSshKeyIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', - 'cloud_init_script' => 'nullable|string', + 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', diff --git a/app/Rules/ValidCloudInitYaml.php b/app/Rules/ValidCloudInitYaml.php new file mode 100644 index 000000000..8116e1161 --- /dev/null +++ b/app/Rules/ValidCloudInitYaml.php @@ -0,0 +1,55 @@ +getMessage()); + } + + return; + } + + // If it doesn't start with #! or #cloud-config, try to parse as YAML + // (some users might omit the #cloud-config header) + try { + Yaml::parse($script); + } catch (ParseException $e) { + $fail('The :attribute must be either a valid bash script (starting with #!) or valid cloud-config YAML. YAML parse error: '.$e->getMessage()); + } + } +} diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php index 1632b48d3..83bedffab 100644 --- a/resources/views/livewire/security/cloud-init-script-form.blade.php +++ b/resources/views/livewire/security/cloud-init-script-form.blade.php @@ -2,7 +2,7 @@ + helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
@if ($modal_mode) diff --git a/tests/Unit/Rules/ValidCloudInitYamlTest.php b/tests/Unit/Rules/ValidCloudInitYamlTest.php new file mode 100644 index 000000000..f3ea906af --- /dev/null +++ b/tests/Unit/Rules/ValidCloudInitYamlTest.php @@ -0,0 +1,174 @@ +validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts valid cloud-config YAML without header', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'YAML' +users: + - name: demo + groups: sudo +packages: + - nginx +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts valid bash script with shebang', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'BASH' +#!/bin/bash +apt update +apt install -y nginx +systemctl start nginx +BASH; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts empty or null script', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $rule->validate('script', '', function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); + + $rule->validate('script', null, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('rejects invalid YAML format', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + $errorMessage = ''; + + $script = <<<'YAML' +#cloud-config +users: + - name: demo + groups: sudo + invalid_indentation +packages: + - nginx +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) { + $valid = false; + $errorMessage = $message; + }); + + expect($valid)->toBeFalse(); + expect($errorMessage)->toContain('YAML'); +}); + +it('rejects script that is neither bash nor valid YAML', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + $errorMessage = ''; + + $script = <<<'INVALID' +this is not valid YAML + and has invalid indentation: + - item + without proper structure { +INVALID; + + $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) { + $valid = false; + $errorMessage = $message; + }); + + expect($valid)->toBeFalse(); + expect($errorMessage)->toContain('bash script'); +}); + +it('accepts complex cloud-config with multiple sections', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'YAML' +#cloud-config +users: + - name: coolify + groups: sudo, docker + shell: /bin/bash + sudo: ['ALL=(ALL) NOPASSWD:ALL'] + ssh_authorized_keys: + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... + +packages: + - docker.io + - docker-compose + - git + - curl + +package_update: true +package_upgrade: true + +runcmd: + - systemctl enable docker + - systemctl start docker + - usermod -aG docker coolify + - echo "Server setup complete" + +write_files: + - path: /etc/docker/daemon.json + content: | + { + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +});