github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/pkg/model/image_target.go (about)

     1  package model
     2  
     3  import (
     4  	"fmt"
     5  
     6  	"github.com/google/go-cmp/cmp"
     7  
     8  	"github.com/tilt-dev/tilt/internal/container"
     9  	"github.com/tilt-dev/tilt/internal/sliceutils"
    10  	"github.com/tilt-dev/tilt/pkg/apis"
    11  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    12  )
    13  
    14  type ImageTarget struct {
    15  	// An apiserver-driven data model for injecting the image into other resources.
    16  	v1alpha1.ImageMapSpec
    17  
    18  	// An apiserver-driven data model for live-updating containers.
    19  	LiveUpdateName       string
    20  	LiveUpdateSpec       v1alpha1.LiveUpdateSpec
    21  	LiveUpdateReconciler bool
    22  
    23  	// An apiserver-driven data model for using docker to build images.
    24  	DockerImageName string
    25  
    26  	// Name for both the CmdImage and the Cmd it manages.
    27  	CmdImageName string
    28  
    29  	BuildDetails BuildDetails
    30  
    31  	// In a live-update-only image, we don't inject the image into the Kubernetes
    32  	// deploy, we only live-update to the deployed object. See this issue:
    33  	//
    34  	// https://github.com/tilt-dev/tilt/issues/4577
    35  	//
    36  	// This is a hacky way to model this right now until we
    37  	// firm up how images work in the apiserver.
    38  	IsLiveUpdateOnly bool
    39  
    40  	FileWatchIgnores []v1alpha1.IgnoreDef
    41  }
    42  
    43  var _ TargetSpec = ImageTarget{}
    44  
    45  func MustNewImageTarget(ref container.RefSelector) ImageTarget {
    46  	return ImageTarget{}.MustWithRef(ref)
    47  }
    48  
    49  func ImageID(ref container.RefSelector) TargetID {
    50  	name := TargetName("")
    51  	if !ref.Empty() {
    52  		name = TargetName(apis.SanitizeName(container.FamiliarString(ref)))
    53  	}
    54  	return TargetID{
    55  		Type: TargetTypeImage,
    56  		Name: name,
    57  	}
    58  }
    59  
    60  func (i ImageTarget) ImageMapName() string {
    61  	return i.ID().Name.String()
    62  }
    63  
    64  func (i ImageTarget) GetFileWatchIgnores() []v1alpha1.IgnoreDef {
    65  	return i.FileWatchIgnores
    66  }
    67  
    68  func (i ImageTarget) WithFileWatchIgnores(ignores []v1alpha1.IgnoreDef) ImageTarget {
    69  	i.FileWatchIgnores = ignores
    70  	return i
    71  }
    72  
    73  // Modified both FileWatchIgnores and ContextIgnores. Useful in tests where they're the same.
    74  func (i ImageTarget) WithIgnores(ignores []v1alpha1.IgnoreDef) ImageTarget {
    75  	i.FileWatchIgnores = ignores
    76  	switch bd := i.BuildDetails.(type) {
    77  	case DockerBuild:
    78  		bd.DockerImageSpec.ContextIgnores = ignores
    79  		i.BuildDetails = bd
    80  	}
    81  	return i
    82  }
    83  
    84  func (i ImageTarget) MustWithRef(ref container.RefSelector) ImageTarget {
    85  	i.ImageMapSpec.Selector = ref.RefFamiliarString()
    86  	i.ImageMapSpec.MatchExact = ref.MatchExact()
    87  	return i
    88  }
    89  
    90  func (i ImageTarget) WithLiveUpdateSpec(name string, luSpec v1alpha1.LiveUpdateSpec) ImageTarget {
    91  	if luSpec.Selector.Kubernetes == nil {
    92  		luSpec.Selector.Kubernetes = i.LiveUpdateSpec.Selector.Kubernetes
    93  	}
    94  	i.LiveUpdateName = name
    95  	i.LiveUpdateSpec = luSpec
    96  	return i
    97  }
    98  
    99  func (i ImageTarget) ID() TargetID {
   100  	return TargetID{
   101  		Type: TargetTypeImage,
   102  		Name: TargetName(apis.SanitizeName(i.ImageMapSpec.Selector)),
   103  	}
   104  }
   105  
   106  func (i ImageTarget) DependencyIDs() []TargetID {
   107  	deps := i.ImageMapDeps()
   108  	result := make([]TargetID, 0, len(deps))
   109  	for _, im := range deps {
   110  		result = append(result, TargetID{
   111  			Type: TargetTypeImage,
   112  			Name: TargetName(im),
   113  		})
   114  	}
   115  	return result
   116  }
   117  
   118  func (i ImageTarget) ImageMapDeps() []string {
   119  	switch bd := i.BuildDetails.(type) {
   120  	case DockerBuild:
   121  		return bd.ImageMaps
   122  	case CustomBuild:
   123  		return bd.ImageMaps
   124  	}
   125  	return nil
   126  }
   127  
   128  func (i ImageTarget) WithImageMapDeps(names []string) ImageTarget {
   129  	switch bd := i.BuildDetails.(type) {
   130  	case DockerBuild:
   131  		bd.ImageMaps = sliceutils.Dedupe(names)
   132  		i.BuildDetails = bd
   133  	case CustomBuild:
   134  		bd.ImageMaps = sliceutils.Dedupe(names)
   135  		i.BuildDetails = bd
   136  	default:
   137  		if len(names) > 0 {
   138  			panic(fmt.Sprintf("image does not support image deps: %v", i.ID()))
   139  		}
   140  	}
   141  	return i
   142  }
   143  
   144  func (i ImageTarget) Validate() error {
   145  	if i.ImageMapSpec.Selector == "" {
   146  		return fmt.Errorf("[Validate] Image target missing image ref: %+v", i.BuildDetails)
   147  	}
   148  
   149  	selector, err := container.SelectorFromImageMap(i.ImageMapSpec)
   150  	if err != nil {
   151  		return fmt.Errorf("[Validate]: %v", err)
   152  	}
   153  
   154  	refs, err := container.NewRefSet(selector, nil)
   155  	if err != nil {
   156  		return fmt.Errorf("[Validate]: %v", err)
   157  	}
   158  
   159  	if err := refs.Validate(); err != nil {
   160  		return fmt.Errorf("[Validate] Image %q refset failed validation: %v", i.ImageMapSpec.Selector, err)
   161  	}
   162  
   163  	switch bd := i.BuildDetails.(type) {
   164  	case DockerBuild:
   165  		if bd.Context == "" {
   166  			return fmt.Errorf("[Validate] Image %q missing build path", i.ImageMapSpec.Selector)
   167  		}
   168  	case CustomBuild:
   169  		if !i.IsLiveUpdateOnly && len(bd.Args) == 0 {
   170  			return fmt.Errorf(
   171  				"[Validate] CustomBuild command must not be empty",
   172  			)
   173  		}
   174  	case DockerComposeBuild:
   175  		if bd.Service == "" {
   176  			return fmt.Errorf("[Validate] DockerComposeBuild missing service name")
   177  		}
   178  	default:
   179  		return fmt.Errorf(
   180  			"[Validate] Image %q has unsupported %T build details", i.ImageMapSpec.Selector, bd)
   181  	}
   182  
   183  	return nil
   184  }
   185  
   186  type BuildDetails interface {
   187  	buildDetails()
   188  }
   189  
   190  func (i ImageTarget) DockerBuildInfo() DockerBuild {
   191  	ret, _ := i.BuildDetails.(DockerBuild)
   192  	return ret
   193  }
   194  
   195  func (i ImageTarget) IsDockerBuild() bool {
   196  	_, ok := i.BuildDetails.(DockerBuild)
   197  	return ok
   198  }
   199  
   200  func (i ImageTarget) CustomBuildInfo() CustomBuild {
   201  	ret, _ := i.BuildDetails.(CustomBuild)
   202  	return ret
   203  }
   204  
   205  func (i ImageTarget) IsCustomBuild() bool {
   206  	_, ok := i.BuildDetails.(CustomBuild)
   207  	return ok
   208  }
   209  
   210  func (i ImageTarget) DockerComposeBuildInfo() DockerComposeBuild {
   211  	ret, _ := i.BuildDetails.(DockerComposeBuild)
   212  	return ret
   213  }
   214  
   215  func (i ImageTarget) IsDockerComposeBuild() bool {
   216  	_, ok := i.BuildDetails.(DockerComposeBuild)
   217  	return ok
   218  }
   219  
   220  func (i ImageTarget) WithDockerImage(spec v1alpha1.DockerImageSpec) ImageTarget {
   221  	return i.WithBuildDetails(DockerBuild{DockerImageSpec: spec})
   222  }
   223  
   224  func (i ImageTarget) WithBuildDetails(details BuildDetails) ImageTarget {
   225  	i.BuildDetails = details
   226  
   227  	cb, ok := details.(CustomBuild)
   228  	isEmptyLiveUpdateSpec := len(i.LiveUpdateSpec.Syncs) == 0 && len(i.LiveUpdateSpec.Execs) == 0
   229  	if ok && cmp.Equal(cb.Args, ToHostCmd(":").Argv) && !isEmptyLiveUpdateSpec {
   230  		// NOTE(nick): This is a hack for the file_sync_only extension
   231  		// until we come up with a real API for specifying live update
   232  		// without an image build.
   233  		i.IsLiveUpdateOnly = true
   234  	}
   235  	return i
   236  }
   237  
   238  func (i ImageTarget) WithOverrideCommand(cmd Cmd) ImageTarget {
   239  	i.ImageMapSpec.OverrideCommand = &v1alpha1.ImageMapOverrideCommand{
   240  		Command: cmd.Argv,
   241  	}
   242  	return i
   243  }
   244  
   245  func (i ImageTarget) LocalPaths() []string {
   246  	switch bd := i.BuildDetails.(type) {
   247  	case DockerBuild:
   248  		return []string{bd.Context}
   249  	case CustomBuild:
   250  		return append([]string(nil), bd.Deps...)
   251  	case DockerComposeBuild:
   252  		return []string{bd.Context}
   253  	}
   254  	return nil
   255  }
   256  
   257  func (i ImageTarget) ClusterNeeds() v1alpha1.ClusterImageNeeds {
   258  	switch bd := i.BuildDetails.(type) {
   259  	case DockerBuild:
   260  		return bd.DockerImageSpec.ClusterNeeds
   261  	case CustomBuild:
   262  		return bd.CmdImageSpec.ClusterNeeds
   263  	}
   264  	return v1alpha1.ClusterImageNeedsBase
   265  }
   266  
   267  // TODO(nick): This method should be deleted. We should just de-dupe and sort LocalPaths once
   268  // when we create it, rather than have a duplicate method that does the "right" thing.
   269  func (i ImageTarget) Dependencies() []string {
   270  	return sliceutils.DedupedAndSorted(i.LocalPaths())
   271  }
   272  
   273  func (i ImageTarget) Refs(cluster *v1alpha1.Cluster) (container.RefSet, error) {
   274  	refs, err := container.RefSetFromImageMap(i.ImageMapSpec, cluster)
   275  	if err != nil {
   276  		return container.RefSet{}, err
   277  	}
   278  
   279  	// I (Nick) am deeply unhappy with the parameters of CustomBuild.  They're not
   280  	// well-specified, and often interact in weird and unpredictable ways.  This
   281  	// function is a good example.
   282  	//
   283  	// custom_build(tag) means "My custom_build script already has a tag that it
   284  	// wants to use". In practice, it becomes the "You can't tell me what to do"
   285  	// flag.
   286  	//
   287  	// custom_build(skips_local_docker) means "My custom_build script doesn't use
   288  	// Docker for storage, so you shouldn't expect to find the image there." In
   289  	// practice, it becomes the "You can't touch my outputs" flag.
   290  	//
   291  	// When used together, you have a script that takes no inputs and doesn't let Tilt
   292  	// fix the outputs. So people use custom_build(tag=x, skips_local_docker=True) to
   293  	// enable all sorts of off-road experimental image-building flows that need better
   294  	// primitives.
   295  	//
   296  	// For now, when we detect this case, we strip off registry information, since
   297  	// the script isn't going to use it anyway.  This is tightly coupled with
   298  	// CustomBuilder, which already has similar logic for handling these two cases
   299  	// together.
   300  	customBuild, ok := i.BuildDetails.(CustomBuild)
   301  	if ok && customBuild.OutputMode == v1alpha1.CmdImageOutputRemote && customBuild.OutputTag != "" {
   302  		refs = refs.WithoutRegistry()
   303  	}
   304  	_, ok = i.BuildDetails.(DockerComposeBuild)
   305  	if ok {
   306  		refs = refs.WithoutRegistry()
   307  	}
   308  
   309  	return refs, nil
   310  }
   311  
   312  // inferImageProperties sets properties on the underlying image spec.
   313  //
   314  // This should eventually go away but helps bridge some of the Tiltfile/engine
   315  // semantics with the apiserver models for now.
   316  func (i ImageTarget) inferImageProperties(clusterNeeds v1alpha1.ClusterImageNeeds, clusterName string) (ImageTarget, error) {
   317  	db, ok := i.BuildDetails.(DockerBuild)
   318  	if ok {
   319  		db.DockerImageSpec.Ref = i.ImageMapSpec.Selector
   320  		db.DockerImageSpec.ClusterNeeds = clusterNeeds
   321  		db.DockerImageSpec.Cluster = clusterName
   322  		i.BuildDetails = db
   323  	}
   324  
   325  	cb, ok := i.BuildDetails.(CustomBuild)
   326  	if ok {
   327  		cb.CmdImageSpec.Ref = i.ImageMapSpec.Selector
   328  		cb.CmdImageSpec.ClusterNeeds = clusterNeeds
   329  		cb.CmdImageSpec.Cluster = clusterName
   330  		i.BuildDetails = cb
   331  	}
   332  
   333  	return i, nil
   334  }
   335  
   336  func ImageTargetsByID(iTargets []ImageTarget) map[TargetID]ImageTarget {
   337  	result := make(map[TargetID]ImageTarget, len(iTargets))
   338  	for _, target := range iTargets {
   339  		result[target.ID()] = target
   340  	}
   341  	return result
   342  }
   343  
   344  type DockerBuild struct {
   345  	v1alpha1.DockerImageSpec
   346  }
   347  
   348  func (DockerBuild) buildDetails() {}
   349  
   350  type CustomBuild struct {
   351  	v1alpha1.CmdImageSpec
   352  
   353  	// Deps is a list of file paths that are dependencies of this command.
   354  	//
   355  	// TODO(nick): This creates a FileWatch. We should add a RestartOn field
   356  	// to CmdImageSpec that points to the FileWatch.
   357  	Deps []string
   358  }
   359  
   360  func (CustomBuild) buildDetails() {}
   361  
   362  func (cb CustomBuild) WithTag(t string) CustomBuild {
   363  	cb.CmdImageSpec.OutputTag = t
   364  	return cb
   365  }
   366  
   367  func (cb CustomBuild) SkipsPush() bool {
   368  	return cb.OutputMode == v1alpha1.CmdImageOutputLocalDockerAndRemote ||
   369  		cb.OutputMode == v1alpha1.CmdImageOutputRemote
   370  }
   371  
   372  type DockerComposeBuild struct {
   373  	// Service is the name of the Docker Compose service as defined in docker-compose.yaml.
   374  	Service string
   375  
   376  	// Context is the build context absolute path.
   377  	Context string
   378  }
   379  
   380  func (d DockerComposeBuild) buildDetails() {
   381  }