Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: CI

"on":
push:
pull_request:

permissions:
contents: read

jobs:
test:
name: Build and test
runs-on: ubuntu-latest
env:
GOFLAGS: -mod=vendor
steps:
- name: Check out repository
# v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Comment thread
navaneeth-dev marked this conversation as resolved.

- name: Set up Go
# v7.0.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e
with:
go-version-file: go.mod
cache: true

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,35 @@ In practice that limits the transpiler to basic Butane features such as users, g
- End to end integration with Flatcar and ClusterAPI.
- Documentation covering supported fields and known limitations.

### Usage

Build `bt`, then pass a cloud-config file or pipe one on standard input:

```sh
go build -o bt ./cmd/bt
bt cloud-config.yaml > butane.yaml
# or: cat cloud-config.yaml | bt -o butane.yaml
```

`bt` currently converts only the Cluster API worker `users` subset. A config
is either converted completely or rejected; unsupported fields are never
silently discarded.

| cloud-config field | Butane field |
| ----------------------- | --------------------- |
| `name` | `name` |
| `passwd` | `password_hash` |
| `gecos` | `gecos` |
| `homedir` | `home_dir` |
| `shell` | `shell` |
| `ssh_authorized_keys` | `ssh_authorized_keys` |

Password hashes remain locked, matching cloud-init's default
`lock_passwd: true`. Supplementary and primary groups, sudo configuration,
password unlocking, and every non-`users` stanza are not yet supported.
Jinja templates are not evaluated and are rejected; render them before passing
the resulting cloud-config to `bt`.

### A Flatcar Container Linux project

Flatcar Container Linux is a fully open source, minimal-footprint, secure by default and always up-to-date Linux distribution for running containers at scale.
Expand Down
105 changes: 105 additions & 0 deletions cmd/bt/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"

"github.com/flatcar/Butane-init"
"github.com/spf13/cobra"
)

var errUsage = errors.New("invalid command usage")

func main() {
cmd := newCommand(os.Stdin, os.Stdout, os.Stderr)
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
if errors.Is(err, errUsage) {
os.Exit(2)
}
os.Exit(1)
}
}

func newCommand(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command {
var outputPath string
cmd := &cobra.Command{
Use: "bt [input-file]",
Short: "Transpile cloud-config YAML to Flatcar Butane YAML",
SilenceErrors: true,
SilenceUsage: true,
Args: func(_ *cobra.Command, args []string) error {
if len(args) > 1 {
return fmt.Errorf("%w: expected at most one input file", errUsage)
}
return nil
},
RunE: func(_ *cobra.Command, args []string) error {
input, err := readInput(stdin, args)
if err != nil {
return err
}
output, err := transpile.Transpile(input)
if err != nil {
return err
}
if outputPath == "" || outputPath == "-" {
_, err = stdout.Write(output)
return err
}
return writeAtomically(outputPath, output)
},
}
cmd.SetIn(stdin)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return fmt.Errorf("%w: %v", errUsage, err)
})
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "write output to a file instead of stdout")
return cmd
}

func readInput(stdin io.Reader, args []string) ([]byte, error) {
if len(args) == 0 || args[0] == "-" {
input, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("read stdin: %w", err)
}
return input, nil
}
input, err := os.ReadFile(args[0])
if err != nil {
return nil, fmt.Errorf("read %q: %w", args[0], err)
}
return input, nil
}

func writeAtomically(path string, contents []byte) error {
dir := filepath.Dir(path)
temp, err := os.CreateTemp(dir, ".bt-*")
if err != nil {
return fmt.Errorf("create temporary output: %w", err)
}
tempPath := temp.Name()
defer os.Remove(tempPath)

if err := temp.Chmod(0o600); err != nil {
temp.Close()
return fmt.Errorf("set output permissions: %w", err)
}
if _, err := temp.Write(contents); err != nil {
temp.Close()
return fmt.Errorf("write output: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close output: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("replace %q: %w", path, err)
}
return nil
}
43 changes: 43 additions & 0 deletions cmd/bt/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"bytes"
"os"
"path/filepath"
"testing"
)

func TestCommandTranspilesStdinToStdout(t *testing.T) {
stdin := bytes.NewBufferString("#cloud-config\nusers: []\n")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd := newCommand(stdin, stdout, stderr)
cmd.SetArgs(nil)

if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v; stderr = %q", err, stderr.String())
}

want := "version: 1.1.0\nvariant: flatcar\n"
if got := stdout.String(); got != want {
t.Fatalf("stdout mismatch\nwant:\n%s\ngot:\n%s", want, got)
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty", got)
}
}

func TestWriteAtomicallyCreatesPrivateOutput(t *testing.T) {
path := filepath.Join(t.TempDir(), "butane.yaml")
if err := writeAtomically(path, []byte("contents")); err != nil {
t.Fatalf("writeAtomically() error = %v", err)
}

info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
t.Fatalf("output permissions = %o, want %o", got, want)
}
}
27 changes: 27 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module github.com/flatcar/Butane-init

go 1.27.0

require (
github.com/coreos/ignition/v2 v2.27.0
github.com/goccy/go-yaml v1.19.2
github.com/spf13/cobra v1.10.2
)

require (
github.com/aws/aws-sdk-go-v2 v1.43.4 // indirect
github.com/clarketm/json v1.17.1 // indirect
github.com/coreos/go-json v0.0.0-20230131223807-18775e0fb4fb // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/vincent-petithory/dataurl v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
48 changes: 48 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
github.com/clarketm/json v1.17.1 h1:U1IxjqJkJ7bRK4L6dyphmoO840P6bdhPdbbLySourqI=
github.com/clarketm/json v1.17.1/go.mod h1:ynr2LRfb0fQU34l07csRNBTcivjySLLiY1YzQqKVfdo=
github.com/coreos/go-json v0.0.0-20230131223807-18775e0fb4fb h1:rmqyI19j3Z/74bIRhuC59RB442rXUazKNueVpfJPxg4=
github.com/coreos/go-json v0.0.0-20230131223807-18775e0fb4fb/go.mod h1:rcFZM3uxVvdyNmsAV2jopgPD1cs5SPWJWU5dOz2LUnw=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/coreos/ignition/v2 v2.27.0 h1:kEWbmy7HnDeOAOxQOrZFPcyvL2GPCOZSW6Tu+f7IhTA=
github.com/coreos/ignition/v2 v2.27.0/go.mod h1:/TFCOigkyeJze8G74Dev5MXB9Zcz4I+GLgHrBr/cTrA=
github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687 h1:uSmlDgJGbUB0bwQBcZomBTottKwEDF5fF8UjSwKSzWM=
github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687/go.mod h1:Salmysdw7DAVuobBW/LwsKKgpyCPHUhjyJoMJD+ZJiI=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
24 changes: 24 additions & 0 deletions testcases/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Cluster API cloud-config fixtures

These fixtures are adapted from Cluster API v1.11.1 at commit
`dc0fb87601071371044f60c59096ecad1b84397f`:

- The
[cloud-init users template](https://github.com/kubernetes-sigs/cluster-api/blob/dc0fb87601071371044f60c59096ecad1b84397f/bootstrap/kubeadm/internal/cloudinit/users.go#L20-L57)
defines the emitted field names and value shapes.
- The
[populated user test](https://github.com/kubernetes-sigs/cluster-api/blob/dc0fb87601071371044f60c59096ecad1b84397f/bootstrap/kubeadm/internal/ignition/clc/clc_test.go#L85-L99)
supplies representative values.
- The
[cloud-init users and groups documentation](https://docs.cloud-init.io/en/24.2/reference/modules.html#users-and-groups)
defines the input semantics.

Secrets and SSH keys are nonfunctional test values. The fixtures are deliberately
small excerpts of Cluster API output so each one isolates a transpilation
boundary.

| Fixture | Purpose | Current result |
| --- | --- | --- |
| `cluster-api-supported-user.yaml` | Every currently supported user field | Success |
| `cluster-api-groups.yaml` | Cluster API's comma-separated groups and primary group | Rejected until group support lands |
| `cluster-api-deferred-fields.yaml` | Remaining rendered account-policy fields | Rejected as unsupported |
7 changes: 7 additions & 0 deletions testcases/cluster-api-deferred-fields.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#cloud-config
# Cluster API renders these account-policy fields as scalar values.
users:
- name: fixture-user
inactive: true
lock_passwd: false
sudo: "ALL=(ALL) NOPASSWD:ALL"
6 changes: 6 additions & 0 deletions testcases/cluster-api-groups.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#cloud-config
# Cluster API renders additional groups as one comma-separated scalar.
users:
- name: foo
groups: "foo, bar"
primary_group: foo
10 changes: 10 additions & 0 deletions testcases/cluster-api-supported-user.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#cloud-config
# Adapted from the Cluster API v1.11.1 populated-user test.
users:
- name: foo
passwd: "$6$REDACTED_TEST_HASH"
gecos: Foo B. Bar
homedir: /home/foo
shell: /bin/false
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIREDACTED fixture@example
Loading
Loading