Skip to content
Merged
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
259 changes: 254 additions & 5 deletions internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"

Expand All @@ -18,14 +22,38 @@ import (
"github.com/meigma/imgcli/schemas/core"
)

const defaultBuildOutputDir = "dist"
const (
defaultBuildOutputDir = "dist"

artifactMetadataAPIVersion = "imgcli.meigma.io/v0alpha1"
artifactMetadataKind = "ArtifactMetadata"
artifactMetadataFileMode = 0o600
artifactMetadataSuffix = ".artifact.json"

buildSHA256PrefixLength = 12
flagBuildFormat = "format"
tablePaddingWidth = 2
)

type buildOutputFormat string

const (
buildOutputFormatTable buildOutputFormat = "table"
buildOutputFormatJSON buildOutputFormat = "json"
buildOutputFormatPaths buildOutputFormat = "paths"
)

func newBuildCommand(rt *runtime) *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "build CONFIG",
Short: "Build disk image artifacts from configuration",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
format, err := parseBuildOutputFormat(cmd)
if err != nil {
return err
}

config, err := loadImageConfig(args[0])
if err != nil {
return err
Expand All @@ -36,9 +64,22 @@ func newBuildCommand(rt *runtime) *cobra.Command {
return err
}

return printBuildArtifacts(rt.opts.stdout(), result)
artifacts, err := writeBuildArtifactMetadata(result)
if err != nil {
return err
}

return printBuildArtifacts(rt.opts.stdout(), result, artifacts, format)
},
}

cmd.Flags().String(
flagBuildFormat,
string(buildOutputFormatTable),
"Output format: table, json, or paths",
)

return cmd
}

type incusOSBuildPorts struct {
Expand Down Expand Up @@ -151,16 +192,224 @@ func runIncusOSBuild(
return result, nil
}

func printBuildArtifacts(output io.Writer, result providers.BuildResult) error {
type builtArtifactOutput struct {
artifact providers.BuiltArtifact
metadataPath string
}

type buildOutputSummary struct {
Image core.Image `json:"image"`
Artifacts []buildArtifactOutputSummary `json:"artifacts"`
}

type buildArtifactOutputSummary struct {
ArtifactKey core.ArtifactKey `json:"artifactKey"`
Variant core.VariantName `json:"variant"`
Provider core.ProviderName `json:"provider"`
Os string `json:"os"`
Architecture core.Architecture `json:"architecture"`
Format core.ArtifactFormat `json:"format"`
Path string `json:"path"`
MetadataPath string `json:"metadataPath"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}

func parseBuildOutputFormat(cmd *cobra.Command) (buildOutputFormat, error) {
value, err := cmd.Flags().GetString(flagBuildFormat)
if err != nil {
return "", fmt.Errorf("read build output format: %w", err)
}

switch buildOutputFormat(strings.ToLower(strings.TrimSpace(value))) {
case buildOutputFormatTable:
return buildOutputFormatTable, nil
case buildOutputFormatJSON:
return buildOutputFormatJSON, nil
case buildOutputFormatPaths:
return buildOutputFormatPaths, nil
default:
return "", fmt.Errorf("invalid build output format %q: expected table, json, or paths", value)
}
}

func writeBuildArtifactMetadata(result providers.BuildResult) ([]builtArtifactOutput, error) {
artifacts := make([]builtArtifactOutput, 0, len(result.Artifacts))
for _, artifact := range result.Artifacts {
if _, err := fmt.Fprintln(output, artifact.Path); err != nil {
metadataPath := buildArtifactMetadataPath(artifact.Path)
metadata := buildArtifactMetadata(result.Plan, artifact)
if err := writeJSONFile(metadataPath, metadata); err != nil {
return nil, fmt.Errorf("write artifact metadata %q: %w", metadataPath, err)
}

artifacts = append(artifacts, builtArtifactOutput{
artifact: artifact,
metadataPath: metadataPath,
})
}

return artifacts, nil
}

func buildArtifactMetadata(plan providers.Plan, artifact providers.BuiltArtifact) imgschemas.ArtifactMetadata {
resolved := resolvedArtifactForOutput(plan, artifact.Plan)
resolved.Path = artifact.Path
resolved.Digest = "sha256:" + artifact.SHA256
resolved.Size = artifact.Size

return imgschemas.ArtifactMetadata{
ApiVersion: artifactMetadataAPIVersion,
Kind: artifactMetadataKind,
Artifact: resolved,
}
}

func writeJSONFile(path string, value any) error {
temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}

tempPath := temp.Name()
committed := false
defer func() {
if !committed {
_ = os.Remove(tempPath)
}
}()

encoder := json.NewEncoder(temp)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
_ = temp.Close()
return fmt.Errorf("encode JSON: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tempPath, artifactMetadataFileMode); err != nil {
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("publish temp file: %w", err)
}
committed = true

return nil
}

func printBuildArtifacts(
output io.Writer,
result providers.BuildResult,
artifacts []builtArtifactOutput,
format buildOutputFormat,
) error {
switch format {
case buildOutputFormatTable:
return printBuildArtifactsTable(output, artifacts)
case buildOutputFormatJSON:
return printBuildArtifactsJSON(output, result, artifacts)
case buildOutputFormatPaths:
return printBuildArtifactPaths(output, artifacts)
default:
return fmt.Errorf("unsupported build output format %q", format)
}
}

func printBuildArtifactsTable(output io.Writer, artifacts []builtArtifactOutput) error {
table := tabwriter.NewWriter(output, 0, 0, tablePaddingWidth, ' ', 0)
if _, err := fmt.Fprintln(
table,
"VARIANT\tOS\tARCH\tFORMAT\tSIZE_BYTES\tSHA256_PREFIX\tARTIFACT\tMETADATA",
); err != nil {
return fmt.Errorf("write build artifact table header: %w", err)
}

for _, artifact := range artifacts {
plan := artifact.artifact.Plan
if _, err := fmt.Fprintf(
table,
"%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
plan.Variant,
plan.OperatingSystem,
plan.Architecture,
plan.Format,
artifact.artifact.Size,
shortSHA256(artifact.artifact.SHA256),
artifact.artifact.Path,
artifact.metadataPath,
); err != nil {
return fmt.Errorf("write build artifact table row: %w", err)
}
}

if err := table.Flush(); err != nil {
return fmt.Errorf("flush build artifact table: %w", err)
}
return nil
}

func printBuildArtifactsJSON(
output io.Writer,
result providers.BuildResult,
artifacts []builtArtifactOutput,
) error {
encoder := json.NewEncoder(output)
if err := encoder.Encode(buildOutputForJSON(result, artifacts)); err != nil {
return fmt.Errorf("write build artifact summary: %w", err)
}
return nil
}

func printBuildArtifactPaths(output io.Writer, artifacts []builtArtifactOutput) error {
for _, artifact := range artifacts {
if _, err := fmt.Fprintln(output, artifact.artifact.Path); err != nil {
return fmt.Errorf("write build artifact path: %w", err)
}
}

return nil
}

func buildOutputForJSON(
result providers.BuildResult,
artifacts []builtArtifactOutput,
) buildOutputSummary {
summary := buildOutputSummary{
Image: result.Plan.Image,
Artifacts: make([]buildArtifactOutputSummary, 0, len(artifacts)),
}

for _, artifact := range artifacts {
plan := artifact.artifact.Plan
summary.Artifacts = append(summary.Artifacts, buildArtifactOutputSummary{
ArtifactKey: plan.Key,
Variant: plan.Variant,
Provider: result.Plan.Provider,
Os: plan.OperatingSystem,
Architecture: plan.Architecture,
Format: plan.Format,
Path: artifact.artifact.Path,
MetadataPath: artifact.metadataPath,
Size: artifact.artifact.Size,
SHA256: artifact.artifact.SHA256,
})
}

return summary
}

func buildArtifactMetadataPath(path string) string {
return path + artifactMetadataSuffix
}

func shortSHA256(sha256Digest string) string {
if len(sha256Digest) <= buildSHA256PrefixLength {
return sha256Digest
}
return sha256Digest[:buildSHA256PrefixLength]
}

func newCacheStore(cfg Config) (*cache.DiskStore, error) {
options := []cache.Option{
cache.WithMaxSizeBytes(cfg.CacheMaxSizeBytes),
Expand Down
32 changes: 18 additions & 14 deletions internal/cli/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,25 @@ func resolvedPlanForOutput(plan providers.Plan) core.ResolvedPlan {
}

for _, artifact := range plan.Artifacts {
resolved.Artifacts[artifact.Key] = core.ResolvedArtifact{
ArtifactKey: artifact.Key,
ImageName: string(plan.Image.Name),
Version: plan.Version,
Variant: artifact.Variant,
Provider: plan.Provider,
Os: artifact.OperatingSystem,
Architecture: artifact.Architecture,
Format: artifact.Format,
MediaType: artifact.MediaType,
Path: artifact.OutputPath,
Labels: artifact.Labels,
Annotations: artifact.Annotations,
}
resolved.Artifacts[artifact.Key] = resolvedArtifactForOutput(plan, artifact)
}

return resolved
}

func resolvedArtifactForOutput(plan providers.Plan, artifact providers.ArtifactPlan) core.ResolvedArtifact {
return core.ResolvedArtifact{
ArtifactKey: artifact.Key,
ImageName: string(plan.Image.Name),
Version: plan.Version,
Variant: artifact.Variant,
Provider: plan.Provider,
Os: artifact.OperatingSystem,
Architecture: artifact.Architecture,
Format: artifact.Format,
MediaType: artifact.MediaType,
Path: artifact.OutputPath,
Labels: artifact.Labels,
Annotations: artifact.Annotations,
}
}
Loading