feat: add YAML validation for cloud-init scripts
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 <noreply@anthropic.com>
This commit is contained in:
parent
a3cecff97b
commit
d93a13eeee
5 changed files with 232 additions and 3 deletions
|
|
@ -36,7 +36,7 @@ protected function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'script' => 'required|string',
|
'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ protected function rules(): array
|
||||||
'selectedHetznerSshKeyIds.*' => 'integer',
|
'selectedHetznerSshKeyIds.*' => 'integer',
|
||||||
'enable_ipv4' => 'required|boolean',
|
'enable_ipv4' => 'required|boolean',
|
||||||
'enable_ipv6' => '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',
|
'save_cloud_init_script' => 'boolean',
|
||||||
'cloud_init_script_name' => 'nullable|string|max:255',
|
'cloud_init_script_name' => 'nullable|string|max:255',
|
||||||
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
|
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
|
||||||
|
|
|
||||||
55
app/Rules/ValidCloudInitYaml.php
Normal file
55
app/Rules/ValidCloudInitYaml.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Symfony\Component\Yaml\Exception\ParseException;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
class ValidCloudInitYaml implements ValidationRule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the validation rule.
|
||||||
|
*
|
||||||
|
* Validates that the cloud-init script is either:
|
||||||
|
* - Valid YAML format (for cloud-config)
|
||||||
|
* - Valid bash script (starting with #!)
|
||||||
|
* - Empty/null (optional field)
|
||||||
|
*/
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (empty($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$script = trim($value);
|
||||||
|
|
||||||
|
// If it's a bash script (starts with shebang), skip YAML validation
|
||||||
|
if (str_starts_with($script, '#!')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a cloud-config file (starts with #cloud-config), validate YAML
|
||||||
|
if (str_starts_with($script, '#cloud-config')) {
|
||||||
|
// Remove the #cloud-config header and validate the rest as YAML
|
||||||
|
$yamlContent = preg_replace('/^#cloud-config\s*/m', '', $script, 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Yaml::parse($yamlContent);
|
||||||
|
} catch (ParseException $e) {
|
||||||
|
$fail('The :attribute must be valid YAML format. Error: '.$e->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<x-forms.input id="name" label="Script Name" helper="A descriptive name for this cloud-init script." required />
|
<x-forms.input id="name" label="Script Name" helper="A descriptive name for this cloud-init script." required />
|
||||||
|
|
||||||
<x-forms.textarea id="script" label="Script Content" rows="12"
|
<x-forms.textarea id="script" label="Script Content" rows="12"
|
||||||
helper="Enter your cloud-init script. Supports both bash scripts and cloud-config YAML format." required />
|
helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
@if ($modal_mode)
|
@if ($modal_mode)
|
||||||
|
|
|
||||||
174
tests/Unit/Rules/ValidCloudInitYamlTest.php
Normal file
174
tests/Unit/Rules/ValidCloudInitYamlTest.php
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Rules\ValidCloudInitYaml;
|
||||||
|
|
||||||
|
it('accepts valid cloud-config YAML with header', function () {
|
||||||
|
$rule = new ValidCloudInitYaml;
|
||||||
|
$valid = true;
|
||||||
|
|
||||||
|
$script = <<<'YAML'
|
||||||
|
#cloud-config
|
||||||
|
users:
|
||||||
|
- name: demo
|
||||||
|
groups: sudo
|
||||||
|
shell: /bin/bash
|
||||||
|
packages:
|
||||||
|
- nginx
|
||||||
|
- git
|
||||||
|
runcmd:
|
||||||
|
- echo "Hello World"
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$rule->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();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue