compose.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package compose
  14. import (
  15. "context"
  16. "errors"
  17. "fmt"
  18. "io"
  19. "strconv"
  20. "strings"
  21. "sync"
  22. "github.com/compose-spec/compose-go/v2/types"
  23. "github.com/docker/buildx/store/storeutil"
  24. "github.com/docker/cli/cli/command"
  25. "github.com/docker/cli/cli/config/configfile"
  26. "github.com/docker/cli/cli/flags"
  27. "github.com/docker/cli/cli/streams"
  28. "github.com/jonboulle/clockwork"
  29. "github.com/moby/moby/api/types/container"
  30. "github.com/moby/moby/api/types/swarm"
  31. "github.com/moby/moby/client"
  32. "github.com/sirupsen/logrus"
  33. "github.com/docker/compose/v5/pkg/api"
  34. "github.com/docker/compose/v5/pkg/dryrun"
  35. )
  36. type Option func(service *composeService) error
  37. // NewComposeService creates a Compose service using Docker CLI.
  38. // This is the standard constructor that requires command.Cli for full functionality.
  39. //
  40. // Example usage:
  41. //
  42. // dockerCli, _ := command.NewDockerCli()
  43. // service := NewComposeService(dockerCli)
  44. //
  45. // For advanced configuration with custom overrides, use ServiceOption functions:
  46. //
  47. // service := NewComposeService(dockerCli,
  48. // WithPrompt(prompt.NewPrompt(cli.In(), cli.Out()).Confirm),
  49. // WithOutputStream(customOut),
  50. // WithErrorStream(customErr),
  51. // WithInputStream(customIn))
  52. //
  53. // Or set all streams at once:
  54. //
  55. // service := NewComposeService(dockerCli,
  56. // WithStreams(customOut, customErr, customIn))
  57. func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, error) {
  58. s := &composeService{
  59. dockerCli: dockerCli,
  60. clock: clockwork.NewRealClock(),
  61. maxConcurrency: -1,
  62. dryRun: false,
  63. }
  64. for _, option := range options {
  65. if err := option(s); err != nil {
  66. return nil, err
  67. }
  68. }
  69. if s.prompt == nil {
  70. s.prompt = func(message string, defaultValue bool) (bool, error) {
  71. fmt.Println(message)
  72. logrus.Warning("Compose is running without a 'prompt' component to interact with user")
  73. return defaultValue, nil
  74. }
  75. }
  76. if s.events == nil {
  77. s.events = &ignore{}
  78. }
  79. // If custom streams were provided, wrap the Docker CLI to use them
  80. if s.outStream != nil || s.errStream != nil || s.inStream != nil {
  81. s.dockerCli = s.wrapDockerCliWithStreams(dockerCli)
  82. }
  83. return s, nil
  84. }
  85. // WithStreams sets custom I/O streams for output and interaction
  86. func WithStreams(out, err io.Writer, in io.Reader) Option {
  87. return func(s *composeService) error {
  88. s.outStream = out
  89. s.errStream = err
  90. s.inStream = in
  91. return nil
  92. }
  93. }
  94. // WithOutputStream sets a custom output stream
  95. func WithOutputStream(out io.Writer) Option {
  96. return func(s *composeService) error {
  97. s.outStream = out
  98. return nil
  99. }
  100. }
  101. // WithErrorStream sets a custom error stream
  102. func WithErrorStream(err io.Writer) Option {
  103. return func(s *composeService) error {
  104. s.errStream = err
  105. return nil
  106. }
  107. }
  108. // WithInputStream sets a custom input stream
  109. func WithInputStream(in io.Reader) Option {
  110. return func(s *composeService) error {
  111. s.inStream = in
  112. return nil
  113. }
  114. }
  115. // WithContextInfo sets custom Docker context information
  116. func WithContextInfo(info api.ContextInfo) Option {
  117. return func(s *composeService) error {
  118. s.contextInfo = info
  119. return nil
  120. }
  121. }
  122. // WithProxyConfig sets custom HTTP proxy configuration for builds
  123. func WithProxyConfig(config map[string]string) Option {
  124. return func(s *composeService) error {
  125. s.proxyConfig = config
  126. return nil
  127. }
  128. }
  129. // WithPrompt configure a UI component for Compose service to interact with user and confirm actions
  130. func WithPrompt(prompt Prompt) Option {
  131. return func(s *composeService) error {
  132. s.prompt = prompt
  133. return nil
  134. }
  135. }
  136. // WithMaxConcurrency defines upper limit for concurrent operations against engine API
  137. func WithMaxConcurrency(maxConcurrency int) Option {
  138. return func(s *composeService) error {
  139. s.maxConcurrency = maxConcurrency
  140. return nil
  141. }
  142. }
  143. // WithDryRun configure Compose to run without actually applying changes
  144. func WithDryRun(s *composeService) error {
  145. s.dryRun = true
  146. cli, err := command.NewDockerCli()
  147. if err != nil {
  148. return err
  149. }
  150. options := flags.NewClientOptions()
  151. options.Context = s.dockerCli.CurrentContext()
  152. err = cli.Initialize(options, command.WithInitializeClient(func(cli *command.DockerCli) (client.APIClient, error) {
  153. return dryrun.NewDryRunClient(s.apiClient(), s.dockerCli)
  154. }))
  155. if err != nil {
  156. return err
  157. }
  158. s.dockerCli = cli
  159. return nil
  160. }
  161. type Prompt func(message string, defaultValue bool) (bool, error)
  162. // AlwaysOkPrompt returns a Prompt implementation that always returns true without user interaction.
  163. func AlwaysOkPrompt() Prompt {
  164. return func(message string, defaultValue bool) (bool, error) {
  165. return true, nil
  166. }
  167. }
  168. // WithEventProcessor configure component to get notified on Compose operation and progress events.
  169. // Typically used to configure a progress UI
  170. func WithEventProcessor(bus api.EventProcessor) Option {
  171. return func(s *composeService) error {
  172. s.events = bus
  173. return nil
  174. }
  175. }
  176. type composeService struct {
  177. dockerCli command.Cli
  178. // prompt is used to interact with user and confirm actions
  179. prompt Prompt
  180. // eventBus collects tasks execution events
  181. events api.EventProcessor
  182. // Optional overrides for specific components (for SDK users)
  183. outStream io.Writer
  184. errStream io.Writer
  185. inStream io.Reader
  186. contextInfo api.ContextInfo
  187. proxyConfig map[string]string
  188. clock clockwork.Clock
  189. maxConcurrency int
  190. dryRun bool
  191. runtimeAPIVersion runtimeVersionCache
  192. }
  193. // Close releases any connections/resources held by the underlying clients.
  194. //
  195. // In practice, this service has the same lifetime as the process, so everything
  196. // will get cleaned up at about the same time regardless even if not invoked.
  197. func (s *composeService) Close() error {
  198. var errs []error
  199. if s.dockerCli != nil {
  200. errs = append(errs, s.apiClient().Close())
  201. }
  202. return errors.Join(errs...)
  203. }
  204. func (s *composeService) apiClient() client.APIClient {
  205. return s.dockerCli.Client()
  206. }
  207. func (s *composeService) configFile() *configfile.ConfigFile {
  208. return s.dockerCli.ConfigFile()
  209. }
  210. // getContextInfo returns the context info - either custom override or dockerCli adapter
  211. func (s *composeService) getContextInfo() api.ContextInfo {
  212. if s.contextInfo != nil {
  213. return s.contextInfo
  214. }
  215. return &dockerCliContextInfo{cli: s.dockerCli}
  216. }
  217. // getProxyConfig returns the proxy config - either custom override or environment-based
  218. func (s *composeService) getProxyConfig() map[string]string {
  219. if s.proxyConfig != nil {
  220. return s.proxyConfig
  221. }
  222. return storeutil.GetProxyConfig(s.dockerCli)
  223. }
  224. func (s *composeService) stdout() *streams.Out {
  225. return s.dockerCli.Out()
  226. }
  227. func (s *composeService) stdin() *streams.In {
  228. return s.dockerCli.In()
  229. }
  230. func (s *composeService) stderr() *streams.Out {
  231. return s.dockerCli.Err()
  232. }
  233. // readCloserAdapter adapts io.Reader to io.ReadCloser
  234. type readCloserAdapter struct {
  235. r io.Reader
  236. }
  237. func (r *readCloserAdapter) Read(p []byte) (int, error) {
  238. return r.r.Read(p)
  239. }
  240. func (r *readCloserAdapter) Close() error {
  241. return nil
  242. }
  243. // wrapDockerCliWithStreams wraps the Docker CLI to intercept and override stream methods
  244. func (s *composeService) wrapDockerCliWithStreams(baseCli command.Cli) command.Cli {
  245. wrapper := &streamOverrideWrapper{
  246. Cli: baseCli,
  247. }
  248. // Wrap custom streams in Docker CLI's stream types
  249. if s.outStream != nil {
  250. wrapper.outStream = streams.NewOut(s.outStream)
  251. }
  252. if s.errStream != nil {
  253. wrapper.errStream = streams.NewOut(s.errStream)
  254. }
  255. if s.inStream != nil {
  256. wrapper.inStream = streams.NewIn(&readCloserAdapter{r: s.inStream})
  257. }
  258. return wrapper
  259. }
  260. // streamOverrideWrapper wraps command.Cli to override streams with custom implementations
  261. type streamOverrideWrapper struct {
  262. command.Cli
  263. outStream *streams.Out
  264. errStream *streams.Out
  265. inStream *streams.In
  266. }
  267. func (w *streamOverrideWrapper) Out() *streams.Out {
  268. if w.outStream != nil {
  269. return w.outStream
  270. }
  271. return w.Cli.Out()
  272. }
  273. func (w *streamOverrideWrapper) Err() *streams.Out {
  274. if w.errStream != nil {
  275. return w.errStream
  276. }
  277. return w.Cli.Err()
  278. }
  279. func (w *streamOverrideWrapper) In() *streams.In {
  280. if w.inStream != nil {
  281. return w.inStream
  282. }
  283. return w.Cli.In()
  284. }
  285. func getCanonicalContainerName(c container.Summary) string {
  286. if len(c.Names) == 0 {
  287. // corner case, sometime happens on removal. return short ID as a safeguard value
  288. return c.ID[:12]
  289. }
  290. // Names return container canonical name /foo + link aliases /linked_by/foo
  291. for _, name := range c.Names {
  292. if strings.LastIndex(name, "/") == 0 {
  293. return name[1:]
  294. }
  295. }
  296. return strings.TrimPrefix(c.Names[0], "/")
  297. }
  298. func getContainerNameWithoutProject(c container.Summary) string {
  299. project := c.Labels[api.ProjectLabel]
  300. defaultName := getDefaultContainerName(project, c.Labels[api.ServiceLabel], c.Labels[api.ContainerNumberLabel])
  301. name := getCanonicalContainerName(c)
  302. if name != defaultName {
  303. // service declares a custom container_name
  304. return name
  305. }
  306. return name[len(project)+1:]
  307. }
  308. // projectFromName builds a types.Project based on actual resources with compose labels set
  309. func (s *composeService) projectFromName(containers Containers, projectName string, services ...string) (*types.Project, error) {
  310. project := &types.Project{
  311. Name: projectName,
  312. Services: types.Services{},
  313. }
  314. if len(containers) == 0 {
  315. return project, fmt.Errorf("no container found for project %q: %w", projectName, api.ErrNotFound)
  316. }
  317. set := types.Services{}
  318. for _, c := range containers {
  319. serviceLabel, ok := c.Labels[api.ServiceLabel]
  320. if !ok {
  321. serviceLabel = getCanonicalContainerName(c)
  322. }
  323. service, ok := set[serviceLabel]
  324. if !ok {
  325. service = types.ServiceConfig{
  326. Name: serviceLabel,
  327. Image: c.Image,
  328. Labels: c.Labels,
  329. }
  330. }
  331. service.Scale = increment(service.Scale)
  332. set[serviceLabel] = service
  333. }
  334. for name, service := range set {
  335. dependencies := service.Labels[api.DependenciesLabel]
  336. if dependencies != "" {
  337. service.DependsOn = types.DependsOnConfig{}
  338. for dc := range strings.SplitSeq(dependencies, ",") {
  339. dcArr := strings.Split(dc, ":")
  340. condition := ServiceConditionRunningOrHealthy
  341. // Let's restart the dependency by default if we don't have the info stored in the label
  342. restart := true
  343. required := true
  344. dependency := dcArr[0]
  345. // backward compatibility
  346. if len(dcArr) > 1 {
  347. condition = dcArr[1]
  348. if len(dcArr) > 2 {
  349. restart, _ = strconv.ParseBool(dcArr[2])
  350. }
  351. }
  352. service.DependsOn[dependency] = types.ServiceDependency{Condition: condition, Restart: restart, Required: required}
  353. }
  354. set[name] = service
  355. }
  356. }
  357. project.Services = set
  358. SERVICES:
  359. for _, qs := range services {
  360. for _, es := range project.Services {
  361. if es.Name == qs {
  362. continue SERVICES
  363. }
  364. }
  365. return project, fmt.Errorf("no such service: %q: %w", qs, api.ErrNotFound)
  366. }
  367. project, err := project.WithSelectedServices(services)
  368. if err != nil {
  369. return project, err
  370. }
  371. return project, nil
  372. }
  373. func increment(scale *int) *int {
  374. i := 1
  375. if scale != nil {
  376. i = *scale + 1
  377. }
  378. return &i
  379. }
  380. func (s *composeService) actualVolumes(ctx context.Context, projectName string) (types.Volumes, error) {
  381. opts := client.VolumeListOptions{
  382. Filters: projectFilter(projectName),
  383. }
  384. volumes, err := s.apiClient().VolumeList(ctx, opts)
  385. if err != nil {
  386. return nil, err
  387. }
  388. actual := types.Volumes{}
  389. for _, vol := range volumes.Items {
  390. actual[vol.Labels[api.VolumeLabel]] = types.VolumeConfig{
  391. Name: vol.Name,
  392. Driver: vol.Driver,
  393. Labels: vol.Labels,
  394. }
  395. }
  396. return actual, nil
  397. }
  398. func (s *composeService) actualNetworks(ctx context.Context, projectName string) (types.Networks, error) {
  399. networks, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{
  400. Filters: projectFilter(projectName),
  401. })
  402. if err != nil {
  403. return nil, err
  404. }
  405. actual := types.Networks{}
  406. for _, net := range networks.Items {
  407. actual[net.Labels[api.NetworkLabel]] = types.NetworkConfig{
  408. Name: net.Name,
  409. Driver: net.Driver,
  410. Labels: net.Labels,
  411. }
  412. }
  413. return actual, nil
  414. }
  415. var swarmEnabled = struct {
  416. once sync.Once
  417. val bool
  418. err error
  419. }{}
  420. func (s *composeService) isSwarmEnabled(ctx context.Context) (bool, error) {
  421. swarmEnabled.once.Do(func() {
  422. res, err := s.apiClient().Info(ctx, client.InfoOptions{})
  423. if err != nil {
  424. swarmEnabled.err = err
  425. }
  426. switch res.Info.Swarm.LocalNodeState {
  427. case swarm.LocalNodeStateInactive, swarm.LocalNodeStateLocked:
  428. swarmEnabled.val = false
  429. default:
  430. swarmEnabled.val = true
  431. }
  432. })
  433. return swarmEnabled.val, swarmEnabled.err
  434. }
  435. // runtimeVersionCache caches a version string after a successful lookup.
  436. // Errors (including context cancellation) are not cached so that
  437. // subsequent calls can retry with a fresh context.
  438. type runtimeVersionCache struct {
  439. mu sync.Mutex
  440. val string
  441. }
  442. // RuntimeAPIVersion returns the negotiated API version that will be used for
  443. // requests to the Docker daemon. It triggers version negotiation via Ping so
  444. // that version-gated request shaping matches the version subsequent API calls
  445. // will actually use.
  446. //
  447. // After negotiation, Compose should never rely on features or request attributes
  448. // not defined by this API version, even if the daemon's raw version is higher.
  449. func (s *composeService) RuntimeAPIVersion(ctx context.Context) (string, error) {
  450. s.runtimeAPIVersion.mu.Lock()
  451. defer s.runtimeAPIVersion.mu.Unlock()
  452. if s.runtimeAPIVersion.val != "" {
  453. return s.runtimeAPIVersion.val, nil
  454. }
  455. cli := s.apiClient()
  456. _, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true})
  457. if err != nil {
  458. return "", err
  459. }
  460. version := cli.ClientVersion()
  461. if version == "" {
  462. return "", fmt.Errorf("docker client returned empty version after successful API negotiation")
  463. }
  464. s.runtimeAPIVersion.val = version
  465. return s.runtimeAPIVersion.val, nil
  466. }