|
1 | 1 | #!/bin/bash |
| 2 | +set -euo pipefail |
2 | 3 |
|
3 | | -# Input: cloud-init YAML file |
4 | | -INPUT_FILE="$1" |
5 | | -# Output: System JSON |
6 | | -OUTPUT_FILE="$2" |
| 4 | +# Usage: |
| 5 | +# ./cloud-init-to-system-json.sh <user-data.yaml> [network-config] |
| 6 | +# |
| 7 | +# Input: |
| 8 | +# USER_DATA_FILE: cloud-init user-data YAML (may include hostname, users, ntp, and optionally network) |
| 9 | +# NETWORK_DATA_FILE (optional): cloud-init network-config YAML (v1 or v2). If given, its network overrides user-data. |
| 10 | +# |
| 11 | +# Output: |
| 12 | +# Prints the resulting system-json to stdout. |
7 | 13 |
|
8 | | -if [[ -z "${INPUT_FILE}" ]]; then |
9 | | - echo "Input file not found." |
10 | | - exit 2 |
| 14 | +USER_DATA_FILE="${1:-}" |
| 15 | +NETWORK_DATA_FILE="${2:-}" |
| 16 | + |
| 17 | +if [[ -z "$USER_DATA_FILE" ]]; then |
| 18 | + echo "Usage: $0 <user-data.yaml> [network-config]" >&2 |
| 19 | + exit 1 |
| 20 | +fi |
| 21 | + |
| 22 | +if ! command -v yq >/dev/null 2>&1; then |
| 23 | + echo "ERROR: 'yq' (mikefarah v4) is required." >&2 |
| 24 | + exit 2 |
11 | 25 | fi |
12 | | -if [[ -z "${OUTPUT_FILE}" ]]; then |
13 | | - echo "Output file not found." |
14 | | - exit 2 |
| 26 | + |
| 27 | +if ! command -v jq >/dev/null 2>&1; then |
| 28 | + echo "ERROR: 'jq' is required." >&2 |
| 29 | + exit 2 |
15 | 30 | fi |
16 | 31 |
|
17 | | -yq '.ntp.servers as $ntp | . * { |
18 | | - network: ( |
19 | | - .network.config // [] | map({ |
20 | | - "dhcp": .dhcp4, |
21 | | - "ip-address": (if .addresses and (.addresses | length > 0) then (.addresses[0] | split("/")[0]) else null end), |
22 | | - "network-mask": (if .addresses and (.addresses | length > 0) then |
23 | | - ( |
24 | | - (.addresses[0] | split("/")[1]) as $cidr | |
| 32 | +yaml2json() { |
| 33 | + local file="$1" |
| 34 | + if [ -n "${file:-}" ] && [ -f "$file" ]; then |
| 35 | + yq eval -o=json "${file}" 2>/dev/null || yq . "${file}" |
| 36 | + else |
| 37 | + echo '{}' |
| 38 | + fi |
| 39 | +} |
| 40 | + |
| 41 | +# Prepare a merged JSON stream for conversion: |
| 42 | +# - If network-config is provided, extract its 'network' block (or treat the whole document as the network block if root is 'network'). |
| 43 | +# Then merge it into user-data as '.network'. |
| 44 | + |
| 45 | +jq -n --slurpfile u <(yaml2json "$USER_DATA_FILE") \ |
| 46 | + --slurpfile n <(yaml2json "$NETWORK_DATA_FILE") \ |
| 47 | +' |
| 48 | + ($n[0].network // $n[0]) as $net | |
| 49 | + if ($net | length) == 0 then |
| 50 | + $u[0] |
| 51 | + else |
| 52 | + $u[0] + {network: $net} |
| 53 | + end |
| 54 | +' | jq ' |
| 55 | +
|
| 56 | +# Helpers |
| 57 | +def as_array: if . == null then [] elif (type=="array") then . else [.] end; |
| 58 | +
|
| 59 | +def cidr_to_ip_and_mask($cidr): |
| 60 | + ($cidr|tostring) as $c |
| 61 | + | ($c | split("/")) as $parts |
| 62 | + | if ($parts|length) != 2 then error("Invalid CIDR: \($c)") else |
| 63 | + ($parts[0]) as $ip |
| 64 | + | ($parts[1]|tonumber) as $p |
| 65 | + | def oct(p): |
| 66 | + if p >= 8 then 255 |
| 67 | + elif p <= 0 then 0 |
| 68 | + else 256 - (pow(2; (8 - p))) |
| 69 | + end; |
| 70 | + [ oct($p), oct($p-8), oct($p-16), oct($p-24) ] as $mask_arr |
| 71 | + | { ip: $ip, mask: ($mask_arr | map(tostring) | join(".")) } |
| 72 | + end; |
| 73 | +
|
| 74 | +def parse_chpasswd_list: |
| 75 | + if . == null then {} |
| 76 | + else ( tostring |
| 77 | + | split("\n") |
| 78 | + | map(select(length>0)) |
| 79 | + | map( (split(":")) |
| 80 | + | if (length>=2) then { (.[0]): (.[1:] | join(":")) } else {} end |
| 81 | + ) |
| 82 | + | add ) |
| 83 | + end; |
| 84 | +
|
| 85 | +def pick_password: |
| 86 | + (.users // []) as $users |
| 87 | + | (.chpasswd.list // null | parse_chpasswd_list) as $kv |
| 88 | + | ( |
| 89 | + ($users | map(select(type=="object" and (.passwd!=null)))) as $hits |
| 90 | + | if ($hits|length)>0 then $hits[0].passwd |
| 91 | + else $kv["system-admin"] |
| 92 | + end |
| 93 | + ); |
| 94 | +
|
| 95 | +# Build interface list for v2 |
| 96 | +def build_ifaces_v2: |
| 97 | + (.network.ethernets // {} | to_entries | sort_by(.key) | map(.value)); |
| 98 | +
|
| 99 | +# Dotted mask -> prefix (for v1 static without CIDR) |
| 100 | +def dotted_to_prefix($m): |
| 101 | + ($m|tostring|split(".")|map(tonumber)) as $o |
| 102 | + | def ones(x): |
| 103 | + if x==255 then 8 |
| 104 | + elif x==254 then 7 |
| 105 | + elif x==252 then 6 |
| 106 | + elif x==248 then 5 |
| 107 | + elif x==240 then 4 |
| 108 | + elif x==224 then 3 |
| 109 | + elif x==192 then 2 |
| 110 | + elif x==128 then 1 |
| 111 | + elif x==0 then 0 |
| 112 | + else error("Invalid netmask octet: \(x)") |
| 113 | + end; |
| 114 | + (ones($o[0]) + ones($o[1]) + ones($o[2]) + ones($o[3])); |
| 115 | +
|
| 116 | +# Build interface list for v1 (legacy) from network.config |
| 117 | +def build_ifaces_v1: |
| 118 | + (.network.config // []) as $cfg |
| 119 | + | ($cfg | map(select(type=="object" and (.type//"")=="physical"))) as $phys |
| 120 | + | ($phys | sort_by(.name // "")) |
| 121 | + | map( |
| 122 | + . as $p |
| 123 | + | ( ($p.subnets // []) | if (length>0) then .[0] else {} end ) as $s |
| 124 | + | ($s.type // "") as $stype |
| 125 | + | if $stype == "dhcp" or $stype == "dhcp4" then |
25 | 126 | { |
26 | | - "0": "0.0.0.0", "1": "128.0.0.0", "2": "192.0.0.0", "3": "224.0.0.0", |
27 | | - "4": "240.0.0.0", "5": "248.0.0.0", "6": "252.0.0.0", "7": "254.0.0.0", |
28 | | - "8": "255.0.0.0", "9": "255.128.0.0", "10": "255.192.0.0", "11": "255.224.0.0", |
29 | | - "12": "255.240.0.0", "13": "255.248.0.0", "14": "255.252.0.0", "15": "255.254.0.0", |
30 | | - "16": "255.255.0.0", "17": "255.255.128.0", "18": "255.255.192.0", "19": "255.255.224.0", |
31 | | - "20": "255.255.240.0", "21": "255.255.248.0", "22": "255.255.252.0", "23": "255.255.254.0", |
32 | | - "24": "255.255.255.0", "25": "255.255.255.128", "26": "255.255.255.192", "27": "255.255.255.224", |
33 | | - "28": "255.255.255.240", "29": "255.255.255.248", "30": "255.255.255.252", "31": "255.255.255.254", |
34 | | - "32": "255.255.255.255" |
35 | | - }[$cidr] |
36 | | - ) |
37 | | - else null end), |
38 | | - "gateway": .gateway4, |
39 | | - "dns-server": .nameservers.addresses, |
40 | | - "ntp-server": $ntp |
41 | | - } | with_entries(select(.value != null))) |
42 | | - ), |
43 | | -}' "${INPUT_FILE}" > "${OUTPUT_FILE}" |
| 127 | + dhcp4: true, |
| 128 | + nameservers: { addresses: (($s.dns_nameservers // ($cfg[] | select(.type=="nameserver") | .address) // []) | as_array | map(tostring)) } |
| 129 | + } |
| 130 | + elif $stype == "static" then |
| 131 | + ( |
| 132 | + ($s.address // null) as $addr |
| 133 | + | ($s.netmask // null) as $mask |
| 134 | + | ($s.gateway // null) as $gw |
| 135 | + | ($s.dns_nameservers // ($cfg[] | select(.type=="nameserver") | .address) // []) as $dns |
| 136 | + | if $addr == null then |
| 137 | + error("v1 static subnet missing address") |
| 138 | + else |
| 139 | + if ($addr | tostring | contains("/")) then |
| 140 | + { addresses: [ ($addr|tostring) ] } |
| 141 | + else |
| 142 | + if $mask == null then |
| 143 | + error("v1 static subnet missing netmask for address \($addr)") |
| 144 | + else |
| 145 | + (dotted_to_prefix($mask)) as $pfx |
| 146 | + | { addresses: [ ($addr|tostring) + "/" + ($pfx|tostring) ] } |
| 147 | + end |
| 148 | + end |
| 149 | + end |
| 150 | + | { |
| 151 | + dhcp4: false |
| 152 | + } |
| 153 | + + . |
| 154 | + + ( if $gw != null then { gateway4: ($gw|tostring) } else {} end ) |
| 155 | + + { nameservers: { addresses: ($dns | as_array | map(tostring)) } } |
| 156 | + ) |
| 157 | + else |
| 158 | + error("v1 subnet type unsupported or missing for interface \($p.name // "unknown")") |
| 159 | + end |
| 160 | + ); |
| 161 | +
|
| 162 | +# iface -> target schema |
| 163 | +def iface_to_entry($ntp_servers): |
| 164 | + . as $iface |
| 165 | + | ($iface.dhcp4 // false) as $dhcp |
| 166 | + | ($iface.nameservers.addresses | as_array | map(tostring)) as $dns |
| 167 | + | if $dhcp then |
| 168 | + { dhcp: true } |
| 169 | + else |
| 170 | + ( |
| 171 | + ($iface.addresses | as_array) as $addrs |
| 172 | + | if ($addrs|length)==0 then error("Static interface missing addresses[]") else . end |
| 173 | + | ($addrs[0] | tostring) as $cidr |
| 174 | + | (cidr_to_ip_and_mask($cidr)) as $am |
| 175 | + | { |
| 176 | + dhcp: false, |
| 177 | + "ip-address": $am.ip, |
| 178 | + "network-mask": $am.mask |
| 179 | + } |
| 180 | + + ( if $iface.gateway4 then { gateway: ($iface.gateway4|tostring) } else {} end ) |
| 181 | + ) |
| 182 | + end |
| 183 | + | . as $base |
| 184 | + | $base |
| 185 | + + ( if ($dns|length)>0 then { "dns-server": ($dns | join(" ")) } else {} end ) |
| 186 | + + ( if ($ntp_servers|length)>0 then { "ntp-server": ($ntp_servers|map(tostring)|join(" ")) } else {} end ); |
44 | 187 |
|
| 188 | +# Entry point |
| 189 | +(.fqdn // .hostname) as $hostname |
| 190 | +| (.ntp.servers | as_array | map(tostring)) as $ntp_servers |
| 191 | +| ( |
| 192 | + if ((.network.version // 2) == 1) then |
| 193 | + build_ifaces_v1 |
| 194 | + else |
| 195 | + build_ifaces_v2 |
| 196 | + end |
| 197 | + ) as $ifaces |
| 198 | +| ($ifaces | map(iface_to_entry($ntp_servers))) as $network |
| 199 | +| (.password // pick_password) as $pwd |
| 200 | +| {} |
| 201 | + + (if $hostname then {hostname:$hostname} else {} end) |
| 202 | + + (if ($network|length)>0 then {network:$network} else {} end) |
| 203 | + + (if $pwd!=null then {system_admin_password:$pwd} else {} end) |
| 204 | +' |
0 commit comments