diff --git a/lib/builder/step/healthcheck.go b/lib/builder/step/healthcheck.go new file mode 100644 index 00000000..a163af64 --- /dev/null +++ b/lib/builder/step/healthcheck.go @@ -0,0 +1,69 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// 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 step + +import ( + "fmt" + "time" + + "github.com/uber/makisu/lib/context" + "github.com/uber/makisu/lib/docker/image" +) + +// HealthcheckStep implements BuildStep and execute HEALTHCHECK directive +type HealthcheckStep struct { + *baseStep + + Interval time.Duration + Timeout time.Duration + StartPeriod time.Duration + Retries int + + Test []string +} + +// NewHealthcheckStep returns a BuildStep from given arguments. +func NewHealthcheckStep( + args string, interval, timeout, startPeriod time.Duration, retries int, + test []string, commit bool) (BuildStep, error) { + + return &HealthcheckStep{ + baseStep: newBaseStep(Healthcheck, args, commit), + Interval: interval, + Timeout: timeout, + StartPeriod: startPeriod, + Retries: retries, + Test: test, + }, nil +} + +// UpdateCtxAndConfig updates mutable states in build context, and generates a +// new image config base on config from previous step. +func (s *HealthcheckStep) UpdateCtxAndConfig( + ctx *context.BuildContext, imageConfig *image.Config) (*image.Config, error) { + + config, err := image.NewImageConfigFromCopy(imageConfig) + if err != nil { + return nil, fmt.Errorf("copy image config: %s", err) + } + config.Config.Healthcheck = &image.HealthConfig{ + Interval: s.Interval, + Timeout: s.Timeout, + StartPeriod: s.StartPeriod, + Retries: s.Retries, + Test: s.Test, + } + return config, nil +} diff --git a/lib/builder/step/healthcheck_test.go b/lib/builder/step/healthcheck_test.go new file mode 100644 index 00000000..f1de46a1 --- /dev/null +++ b/lib/builder/step/healthcheck_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// 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 step + +import ( + "testing" + "time" + + "github.com/uber/makisu/lib/context" + "github.com/uber/makisu/lib/docker/image" + + "github.com/stretchr/testify/require" +) + +func TestHealthcheckStepUpdateCtxAndConfig(t *testing.T) { + require := require.New(t) + + ctx, cleanup := context.BuildContextFixture() + defer cleanup() + + cmd := []string{"CMD", "ls", "/"} + d5, _ := time.ParseDuration("5s") + d0, _ := time.ParseDuration("0s") + step, err := NewHealthcheckStep("", d0, d5, d0, 0, cmd, false) + require.NoError(err) + + c := image.NewDefaultImageConfig() + result, err := step.UpdateCtxAndConfig(ctx, &c) + require.NoError(err) + require.Equal(result.Config.Healthcheck, &image.HealthConfig{ + Interval: d0, + Timeout: d5, + StartPeriod: d0, + Retries: 0, + Test: cmd, + }) +} diff --git a/lib/builder/step/step.go b/lib/builder/step/step.go index 84ff1f40..b0a2861e 100644 --- a/lib/builder/step/step.go +++ b/lib/builder/step/step.go @@ -28,20 +28,21 @@ type Directive string // Set of all valid directives. const ( - Add = Directive("ADD") - Cmd = Directive("CMD") - Copy = Directive("COPY") - Entrypoint = Directive("ENTRYPOINT") - Env = Directive("ENV") - Expose = Directive("EXPOSE") - From = Directive("FROM") - Label = Directive("LABEL") - Maintainer = Directive("MAINTAINER") - Run = Directive("RUN") - Stopsignal = Directive("STOPSIGNAL") - User = Directive("USER") - Volume = Directive("VOLUME") - Workdir = Directive("WORKDIR") + Add = Directive("ADD") + Cmd = Directive("CMD") + Copy = Directive("COPY") + Entrypoint = Directive("ENTRYPOINT") + Env = Directive("ENV") + Expose = Directive("EXPOSE") + From = Directive("FROM") + Healthcheck = Directive("HEALTHCHECK") + Label = Directive("LABEL") + Maintainer = Directive("MAINTAINER") + Run = Directive("RUN") + Stopsignal = Directive("STOPSIGNAL") + User = Directive("USER") + Volume = Directive("VOLUME") + Workdir = Directive("WORKDIR") ) // BuildStep performs build for one build step. @@ -127,6 +128,9 @@ func NewDockerfileStep( case *dockerfile.FromDirective: s, _ := d.(*dockerfile.FromDirective) step, err = NewFromStep(s.Args, s.Image, s.Alias) + case *dockerfile.HealthcheckDirective: + s, _ := d.(*dockerfile.HealthcheckDirective) + step, err = NewHealthcheckStep(s.Args, s.Interval, s.Timeout, s.StartPeriod, s.Retries, s.Test, s.Commit) case *dockerfile.LabelDirective: s, _ := d.(*dockerfile.LabelDirective) step = NewLabelStep(s.Args, s.Labels, s.Commit) diff --git a/lib/docker/image/container_config.go b/lib/docker/image/container_config.go index 16b49119..79173258 100644 --- a/lib/docker/image/container_config.go +++ b/lib/docker/image/container_config.go @@ -28,8 +28,9 @@ type HealthConfig struct { Test []string `json:",omitempty"` // Zero means to inherit. Durations are expressed as integer nanoseconds. - Interval time.Duration `json:",omitempty"` // Interval is the time to wait between checks. - Timeout time.Duration `json:",omitempty"` // Timeout is the time to wait before considering the check to have hung. + Interval time.Duration `json:",omitempty"` // Interval is the time to wait between checks. + Timeout time.Duration `json:",omitempty"` // Timeout is the time to wait before considering the check to have hung. + StartPeriod time.Duration `json:",omitempty"` // The start period for the container to initialize before the retries starts to count down. // Retries is the number of consecutive failures needed to consider a container as unhealthy. // Zero means inherit. diff --git a/lib/parser/dockerfile/directive.go b/lib/parser/dockerfile/directive.go index 2fc8f533..25992f3e 100644 --- a/lib/parser/dockerfile/directive.go +++ b/lib/parser/dockerfile/directive.go @@ -22,21 +22,22 @@ type Directive interface { type directiveConstructor func(*baseDirective, *parsingState) (Directive, error) var directiveConstructors = map[string]directiveConstructor{ - "add": newAddDirective, - "arg": newArgDirective, - "cmd": newCmdDirective, - "copy": newCopyDirective, - "entrypoint": newEntrypointDirective, - "env": newEnvDirective, - "expose": newExposeDirective, - "from": newFromDirective, - "label": newLabelDirective, - "maintainer": newMaintainerDirective, - "run": newRunDirective, - "stopsignal": newStopsignalDirective, - "user": newUserDirective, - "volume": newVolumeDirective, - "workdir": newWorkdirDirective, + "add": newAddDirective, + "arg": newArgDirective, + "cmd": newCmdDirective, + "copy": newCopyDirective, + "entrypoint": newEntrypointDirective, + "env": newEnvDirective, + "expose": newExposeDirective, + "from": newFromDirective, + "healthcheck": newHealthcheckDirective, + "label": newLabelDirective, + "maintainer": newMaintainerDirective, + "run": newRunDirective, + "stopsignal": newStopsignalDirective, + "user": newUserDirective, + "volume": newVolumeDirective, + "workdir": newWorkdirDirective, } // newDirective initializes a directive from a line of a Dockerfile and diff --git a/lib/parser/dockerfile/healthcheck.go b/lib/parser/dockerfile/healthcheck.go new file mode 100644 index 00000000..04185d55 --- /dev/null +++ b/lib/parser/dockerfile/healthcheck.go @@ -0,0 +1,157 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// 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 dockerfile + +import ( + "fmt" + "regexp" + "strconv" + "time" +) + +// HeathcheckDirective represents the "LABEL" dockerfile command. +type HealthcheckDirective struct { + *baseDirective + + Interval time.Duration + Timeout time.Duration + StartPeriod time.Duration + Retries int + + Test []string +} + +// Variables: +// Replaced from ARGs and ENVs from within our stage. +// Formats: +// HEALTHCHECK NONE +// HEALTHCHECK [--interval=] [--timeout=] [--start-period=] [--retries=] \ +// CMD [""...] +// HEALTHCHECK [--interval=] [--timeout=] [--start-period=] [--retries=] \ +// CMD ... +func newHealthcheckDirective(base *baseDirective, state *parsingState) (Directive, error) { + // TODO: regexp is not the ideal solution. + if isNone := regexp.MustCompile(`(?i)^[\s|\\]*none[\s|\\]*$`).MatchString(base.Args); isNone { + return &HealthcheckDirective{ + baseDirective: base, + Test: []string{"None"}, + }, nil + } + cmdIndices := regexp.MustCompile(`(?i)[\s|\\]*cmd[\s|\\]*`).FindStringIndex(base.Args) + if len(cmdIndices) < 2 { + return nil, base.err(fmt.Errorf("CMD not defined")) + } + + flags, err := splitArgs(base.Args[:cmdIndices[0]]) + if err != nil { + return nil, fmt.Errorf("failed to parse interval") + } + + var interval, timeout, startPeriod time.Duration + var retries int + for _, flag := range flags { + if val, ok, err := parseFlag(flag, "interval"); err != nil { + return nil, base.err(err) + } else if ok { + interval, err = time.ParseDuration(val) + if err != nil { + return nil, fmt.Errorf("failed to parse interval") + } + continue + } + + if val, ok, err := parseFlag(flag, "timeout"); err != nil { + return nil, base.err(err) + } else if ok { + timeout, err = time.ParseDuration(val) + if err != nil { + return nil, fmt.Errorf("failed to parse timeout") + } + continue + } + + if val, ok, err := parseFlag(flag, "start-period"); err != nil { + return nil, base.err(err) + } else if ok { + startPeriod, err = time.ParseDuration(val) + if err != nil { + return nil, fmt.Errorf("failed to parse start-period") + } + continue + } + + if val, ok, err := parseFlag(flag, "retries"); err != nil { + return nil, base.err(err) + } else if ok { + retries, err = strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("failed to parse retries") + } + continue + } + + return nil, base.err(fmt.Errorf("Unsupported flag %s", flag)) + } + + // Replace variables. + if state.stageVars == nil { + return nil, base.err(errBeforeFirstFrom) + } + remaining := base.Args[cmdIndices[1]:] + replaced, err := replaceVariables(remaining, state.stageVars) + if err != nil { + return nil, base.err(fmt.Errorf("Failed to replace variables in input: %s", err)) + } + remaining = replaced + + // Parse CMD. + if cmd, ok := parseJSONArray(remaining); ok { + if len(cmd) == 0 { + return nil, base.err(fmt.Errorf("missing CMD arguments: %s", err)) + } + + return &HealthcheckDirective{ + baseDirective: base, + Interval: interval, + Timeout: timeout, + StartPeriod: startPeriod, + Retries: retries, + Test: append([]string{"CMD"}, cmd...), + }, nil + } + + // Verify cmd arg is a valid array, but return the whole arg as one string. + args, err := splitArgs(remaining) + if err != nil { + return nil, base.err(err) + } + if len(args) == 0 { + return nil, base.err(fmt.Errorf("missing CMD arguments: %s", err)) + } + + return &HealthcheckDirective{ + baseDirective: base, + Interval: interval, + Timeout: timeout, + StartPeriod: startPeriod, + Retries: retries, + Test: append([]string{"CMD-SHELL"}, remaining), + }, nil +} + +// Add this command to the build stage. +func (d *HealthcheckDirective) update(state *parsingState) error { + return state.addToCurrStage(d) +} diff --git a/lib/parser/dockerfile/healthcheck_test.go b/lib/parser/dockerfile/healthcheck_test.go new file mode 100644 index 00000000..36b4d8dc --- /dev/null +++ b/lib/parser/dockerfile/healthcheck_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// 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 dockerfile + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNewHealthcheckDirective(t *testing.T) { + buildState := newParsingState(make(map[string]string)) + buildState.stageVars = map[string]string{"prefix": "test_", "suffix": "_test", "comma": ","} + + d15, _ := time.ParseDuration("15s") + d5, _ := time.ParseDuration("5s") + d0, _ := time.ParseDuration("0s") + + tests := []struct { + desc string + succeed bool + input string + interval time.Duration + timeout time.Duration + startPeriod time.Duration + retries int + test []string + }{ + {"none", true, "healthcheck none", d0, d0, d0, 0, []string{"None"}}, + {"none escaped", true, "healthcheck \\\nnoNE", d0, d0, d0, 0, []string{"None"}}, + {"empty cmd", false, "healthcheck cmd", d0, d0, d0, 0, nil}, + {"substitution", true, `healthcheck cMD ["${prefix}this", "cmd${suffix}"]`, d0, d0, d0, 0, []string{"CMD", "test_this", "cmd_test"}}, + {"substitution 2", true, `healthcheck cmd ["this"$comma "cmd"]`, d0, d0, d0, 0, []string{"CMD", "this", "cmd"}}, + {"good cmd", true, "healthcheck --interval=15s --timeout=5s --start-period=5s --retries=10\\\n \\\ncmd this cmd", d15, d5, d5, 10, []string{"CMD-SHELL", "this cmd"}}, + {"quotes", true, `healthcheck cmd "this cmd"`, d0, d0, d0, 0, []string{"CMD-SHELL", "\"this cmd\""}}, + {"quotes 2", true, `healthcheck cmd "this cmd" cmd2 "and cmd 3"`, d0, d0, d0, 0, []string{"CMD-SHELL", "\"this cmd\" cmd2 \"and cmd 3\""}}, + {"substitution", true, "healthcheck cmd ${prefix}this cmd$suffix", d0, d0, d0, 0, []string{"CMD-SHELL", "test_this cmd_test"}}, + {"good json", true, `healthcheck cmd ["this", "cmd"]`, d0, d0, d0, 0, []string{"CMD", "this", "cmd"}}, + {"bad json", false, `healthcheck cmd ["this, "cmd"]`, d0, d0, d0, 0, nil}, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + require := require.New(t) + directive, err := newDirective(test.input, buildState) + if test.succeed { + require.NoError(err) + healthcheck, ok := directive.(*HealthcheckDirective) + require.True(ok) + require.Equal(test.interval, healthcheck.Interval) + require.Equal(test.timeout, healthcheck.Timeout) + require.Equal(test.startPeriod, healthcheck.StartPeriod) + require.Equal(test.retries, healthcheck.Retries) + require.Equal(test.test, healthcheck.Test) + } else { + require.Error(err) + } + }) + } +} diff --git a/testdata/build-context/simple/Dockerfile b/testdata/build-context/simple/Dockerfile index 661640b0..18114e5f 100644 --- a/testdata/build-context/simple/Dockerfile +++ b/testdata/build-context/simple/Dockerfile @@ -3,4 +3,7 @@ FROM debian:8 MAINTAINER foo@bar ENV TEST=testenv LABEL test.label.key=test_label_value +HEALTHCHECK --interval=10s\ + --timeout=30s \ + CMD echo hello || exit 1 RUN touch /home/testfile