Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: sfdisk implementation #1929

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/shared/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ var (
ErrPathConflictsSystemd = errors.New("path conflicts with systemd unit or dropin")
ErrCexWithClevis = errors.New("cannot use cex with clevis")
ErrCexWithKeyFile = errors.New("cannot use key file with cex")
ErrBadSfdiskPretend = errors.New("sfdisk had unexpected output while pretending partition configuration on device")
ErrBadSfdiskCommit = errors.New("sfdisk had unexpected output while committing partition configuration to device")

// Systemd section errors
ErrInvalidSystemdExt = errors.New("invalid systemd unit extension")
Expand Down
28 changes: 28 additions & 0 deletions internal/device_managers/deviceManagers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package device_managers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about partitioners?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah thats a better name; will do!


import (
"github.com/coreos/ignition/v2/config/v3_5_experimental/types"
)

type DeviceManager interface {
CreatePartition(p Partition)
DeletePartition(num int)
Info(num int)
WipeTable(wipe bool)
Pretend() (string, error)
Commit() error
ParseOutput(string, []int) (map[int]Output, error)
}

type Partition struct {
types.Partition
StartSector *int64
SizeInSectors *int64
StartMiB string
SizeMiB string
}

type Output struct {
Start int64
Size int64
}
249 changes: 249 additions & 0 deletions internal/device_managers/sfdisk/sfdisk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
// Copyright 2024 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package sfdisk

import (
"bytes"
"fmt"
"io"
"os/exec"
"regexp"
"strconv"
"strings"

sharedErrors "github.com/coreos/ignition/v2/config/shared/errors"
"github.com/coreos/ignition/v2/config/util"
"github.com/coreos/ignition/v2/internal/device_managers"
"github.com/coreos/ignition/v2/internal/distro"
"github.com/coreos/ignition/v2/internal/log"
)

type Operation struct {
logger *log.Logger
dev string
wipe bool
parts []device_managers.Partition
deletions []int
infos []int
}

// Begin begins an sfdisk operation
func Begin(logger *log.Logger, dev string) *Operation {
return &Operation{logger: logger, dev: dev}
}

// CreatePartition adds the supplied partition to the list of partitions to be created as part of an operation
func (op *Operation) CreatePartition(p device_managers.Partition) {
op.parts = append(op.parts, p)
}

func (op *Operation) DeletePartition(num int) {
op.deletions = append(op.deletions, num)
}

func (op *Operation) Info(num int) {
op.infos = append(op.infos, num)
}

// WipeTable toggles if the table is to be wiped first when committing this operation.
func (op *Operation) WipeTable(wipe bool) {
op.wipe = wipe
}

// Pretend is like Commit() but uses the --no-act flag and returns the output
func (op *Operation) Pretend() (string, error) {
// Handle deletions first
if err := op.handleDeletions(); err != nil {
return "", err
}

if err := op.handleInfo(); err != nil {
return "", err
}

script := op.buildOptions()
cmd := exec.Command("sh", "-c", fmt.Sprintf("echo -e \"%s\" | sudo %s --no-act %s", script, distro.SfdiskCmd(), op.dev))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid passing through the shell here. We should be able to call sfdisk directly and pass in the script via the command's stdin.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, shouldn't need sudo here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I was running into permissions issues for some reason; (I agree) but it was failing on permissions. Maybe because of the "sh" ?

stdout, err := cmd.StdoutPipe()

if err != nil {
return "", err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return "", err
}

if err := cmd.Start(); err != nil {
return "", err
}
output, err := io.ReadAll(stdout)
if err != nil {
return "", err
}

errors, err := io.ReadAll(stderr)
if err != nil {
return "", err
}

if err := cmd.Wait(); err != nil {
return "", fmt.Errorf("failed to pretend to create partitions. Err: %v. Stderr: %v", err, string(errors))
}

return string(output), nil
}

// Commit commits an partitioning operation.
func (op *Operation) Commit() error {
script := op.buildOptions()
if len(script) == 0 {
return nil
}

// If wipe we need to reset the partition table
if op.wipe {
// Erase the existing partition tables
cmd := exec.Command("sudo", distro.WipefsCmd(), "-a", op.dev)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need sudo here.

if _, err := op.logger.LogCmd(cmd, "option wipe selected, and failed to execute on %q", op.dev); err != nil {
return fmt.Errorf("wipe partition table failed: %v", err)
}
}

op.logger.Info("running sfdisk with script: %v", script)
exec.Command("sh", "-c", fmt.Sprintf("echo label: gpt | sudo %s %s", distro.SfdiskCmd(), op.dev))
cmd := exec.Command("sh", "-c", fmt.Sprintf("echo \"%s\" | sudo %s %s", script, distro.SfdiskCmd(), op.dev))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments here.

Probably cleanest is to make a helper function that actually calls sfdisk and takes the script as an argument that it feeds to its stdin?

if _, err := op.logger.LogCmd(cmd, "deleting %d partitions and creating %d partitions on %q", len(op.deletions), len(op.parts), op.dev); err != nil {
return fmt.Errorf("create partitions failed: %v", err)
}

return nil
}

// ParseOutput takes the output from sfdisk. Similarly to sgdisk
// it then uses regex to parse the output into understood values like 'start' 'size' and attempts
// to catch any failures and wrap them to return to the caller.
func (op *Operation) ParseOutput(sfdiskOutput string, partitionNumbers []int) (map[int]device_managers.Output, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK yeah, this is pretty unfortunate. I was expecting --json or -dto also work in combination with--no-act`, but that doesn't seem the case. Let's at least file an RFE upstream for now?

This has a high chance of regression if the output of sfdisk changes. We'll indeed want to make sure we have good coverage for this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah; 100%, Ok will do and tag it here once done.

if len(partitionNumbers) == 0 {
return nil, nil
}

// Prepare the data, and a regex for matching on partitions
partitionRegex := regexp.MustCompile(`^/dev/\S+\s+\S*\s+(\d+)\s+(\d+)\s+\d+\s+\S+\s+\S+\s+\S+.*$`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deserves some explanation.

Also, I feel like we should ignore anything before the "New situation:" line.

output := map[int]device_managers.Output{}
current := device_managers.Output{}
i := 0
lines := strings.Split(sfdiskOutput, "\n")
for _, line := range lines {
matches := partitionRegex.FindStringSubmatch(line)

// Sanity check number of partition entries
if i > len(partitionNumbers) {
return nil, sharedErrors.ErrBadSfdiskPretend
}

// Verify that we are not reading a 'failed' or 'error'
errorRegex := regexp.MustCompile(`(?i)(failed|error)`)
if errorRegex.MatchString(line) {
return nil, fmt.Errorf("%w: sfdisk returned :%v", sharedErrors.ErrBadSfdiskPretend, line)
}

// When we get a match it should be
// Whole line at [0]
// Start at [1]
// Size at [2]
if len(matches) > 2 {
start, err := strconv.Atoi(matches[1])
if err != nil {
return nil, err
}
end, err := strconv.Atoi(matches[2])
if err != nil {
return nil, err
}

current.Start = int64(start)
// Add one due to overlap
current.Size = int64(end - start + 1)
output[partitionNumbers[i]] = current
i++
}
}

return output, nil
}

func (op Operation) buildOptions() string {
var script bytes.Buffer

for _, p := range op.parts {
if p.Number != 0 {
script.WriteString(fmt.Sprintf("%d : ", p.Number))
}

if p.StartSector != nil {
script.WriteString(fmt.Sprintf("start=%d ", *p.StartSector))

}

if p.SizeInSectors != nil {
script.WriteString(fmt.Sprintf("size=%d ", *p.SizeInSectors))
}

if util.NotEmpty(p.TypeGUID) {
script.WriteString(fmt.Sprintf("type=%s ", *p.TypeGUID))
}

if util.NotEmpty(p.GUID) {
script.WriteString(fmt.Sprintf("uuid=%s ", *p.GUID))
}

if p.Label != nil {
script.WriteString(fmt.Sprintf("name=%s ", *p.Label))
}

// Add escaped new line to allow for 1 or more partitions
// i.e "1: size=50 \\n size=10" will result in part 1, and 2
script.WriteString("\\n ")

}

return script.String()
}

func (op *Operation) handleDeletions() error {
for _, num := range op.deletions {
cmd := exec.Command(distro.SfdiskCmd(), "--delete", op.dev, fmt.Sprintf("%d", num))
op.logger.Info("running sfdisk to delete partition %d on %q", num, op.dev)

if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to delete partition %d: %v, output: %s", num, err, output)
}
}
return nil
}

func (op *Operation) handleInfo() error {
for _, num := range op.infos {
cmd := exec.Command(distro.SfdiskCmd(), "--list", op.dev)
op.logger.Info("retrieving information for partition %d on %q", num, op.dev)

output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to retrieve partition info for %d: %v, output: %s", num, err, output)
}
op.logger.Info("partition info: %s", output)
}
return nil
}
108 changes: 108 additions & 0 deletions internal/device_managers/sfdisk/sfdisk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package sfdisk_test

import (
"errors"
"reflect"
"testing"

internalErrors "github.com/coreos/ignition/v2/config/shared/errors"
"github.com/coreos/ignition/v2/internal/device_managers"
"github.com/coreos/ignition/v2/internal/device_managers/sfdisk"
)

func TestPartitionParse(t *testing.T) {
// Define test cases
tests := []struct {
name string
sfdiskOut string
partitionNumbers []int
expectedOutput map[int]device_managers.Output
expectedError error
}{
{
name: "valid input with single partition",
sfdiskOut: `
Disk /dev/vda: 2 GiB, 2147483648 bytes, 4194304 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes

>>> Created a new DOS (MBR) disklabel with disk identifier 0x501fc254.
/dev/vda1: Created a new partition 1 of type 'Linux' and of size 5 KiB.
/dev/vda2: Done.

New situation:
Disklabel type: dos
Disk identifier: 0x501fc254

Device Boot Start End Sectors Size Id Type
/dev/vda1 2048 2057 10 5K 83 Linux
The partition table is unchanged (--no-act).`,
partitionNumbers: []int{1},
expectedOutput: map[int]device_managers.Output{
1: {Start: 2048, Size: 10},
},
expectedError: nil,
},
{
name: "valid input with two partitions",
sfdiskOut: `
Disk /dev/vda: 2 GiB, 2147483648 bytes, 4194304 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes

>>> Created a new DOS (MBR) disklabel with disk identifier 0x8d8dd38c.
/dev/vda1: Created a new partition 1 of type 'Linux' and of size 5 KiB.
/dev/vda2: Created a new partition 2 of type 'Linux' and of size 5 KiB.
/dev/vda3: Done.

New situation:
Disklabel type: dos
Disk identifier: 0x8d8dd38c

Device Boot Start End Sectors Size Id Type
/dev/vda1 2048 2057 10 5K 83 Linux
/dev/vda2 4096 4105 10 5K 83 Linux
The partition table is unchanged (--no-act).`,
partitionNumbers: []int{1, 2},
expectedOutput: map[int]device_managers.Output{
1: {Start: 2048, Size: 10},
2: {Start: 4096, Size: 10},
},
expectedError: nil,
},
{
name: "invalid input with 1 partition starting on sector 0",
sfdiskOut: `
Disk /dev/vda: 2 GiB, 2147483648 bytes, 4194304 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes

>>> Created a new DOS (MBR) disklabel with disk identifier 0xdebbe997.
/dev/vda1: Start sector 0 out of range.
Failed to add #1 partition: Numerical result out of range
Leaving.
`,
partitionNumbers: []int{1},
expectedOutput: map[int]device_managers.Output{
1: {Start: 0, Size: 0},
},
expectedError: internalErrors.ErrBadSfdiskPretend,
},
}
op := sfdisk.Begin(nil, "")
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, err := op.ParseOutput(tt.sfdiskOut, tt.partitionNumbers)
if tt.expectedError != nil {
if !errors.Is(err, tt.expectedError) {
t.Errorf("#%d: bad error: result = %v, expected = %v", i, err, tt.expectedError)
}
} else if !reflect.DeepEqual(output, tt.expectedOutput) {
t.Errorf("#%d: result = %v, expected = %v", i, output, tt.expectedOutput)
}
})
}
}
Loading
Loading