Skip to content

Commit

Permalink
Add script mode support to windows tasks
Browse files Browse the repository at this point in the history
Steps and sidecars can contain a script field. In linux tasks these
scripts are copied into files which are made executable and then
steps are added to the task to execute those files. This commit adds
comparable functionality if scripts are used in a task which will run
on a windows node.

On a windows node the mechanics are different, due to how windows
handles executable files. The key difference is that Tekton needs to
know that a script will run on windows, and how to run the file
(which interpreter to use). This is done through a ‘windows shebang’
line at the start of the script.

The line must begin with ‘#!win’. After that the user needs to provide
the interpreter to use, as well as any necessary arguments. The line
must be written such that the name of the file containing the script to
execute can be appended to the end.

For example, to run the script in the file ‘test.ps1’ with powershell,
the command would usually be ‘powershell -File test.ps1’ and so the
shebang line must be ‘#!win powershell -File’.

If no interpreter is provided (i.e. the shebang line is only ‘#!win’) then
the script contents will be stored in a .cmd file and executed.

Finally, since a pod cannot contain a mix of windows and linux
containers a windows shell image has been added to the Images
structure, which will be used in the place-scripts step when needed
on a windows node.

To maintain parity with other alpha features, task validation for tasks
containing windows scripts will now require the 'enable-api-fields' flag
to be 'alpha'. TaskRuns/Tasks that contain windows scripts will be
rejected if this flag is not set, giving the user immediate feedback.

The integration tests for windows scripts have been updated to reflect
this alpha flag requirement.
  • Loading branch information
DrWadsy authored and tekton-robot committed Sep 6, 2021
1 parent fef771d commit 25f3175
Show file tree
Hide file tree
Showing 11 changed files with 591 additions and 26 deletions.
2 changes: 2 additions & 0 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ var (
gitImage = flag.String("git-image", "", "The container image containing our Git binary.")
kubeconfigWriterImage = flag.String("kubeconfig-writer-image", "", "The container image containing our kubeconfig writer binary.")
shellImage = flag.String("shell-image", "", "The container image containing a shell")
shellImageWin = flag.String("shell-image-win", "", "The container image containing a windows shell")
gsutilImage = flag.String("gsutil-image", "", "The container image containing gsutil")
prImage = flag.String("pr-image", "", "The container image containing our PR binary.")
imageDigestExporterImage = flag.String("imagedigest-exporter-image", "", "The container image containing our image digest exporter binary.")
Expand All @@ -65,6 +66,7 @@ func main() {
GitImage: *gitImage,
KubeconfigWriterImage: *kubeconfigWriterImage,
ShellImage: *shellImage,
ShellImageWin: *shellImageWin,
GsutilImage: *gsutilImage,
PRImage: *prImage,
ImageDigestExporterImage: *imageDigestExporterImage,
Expand Down
5 changes: 4 additions & 1 deletion config/controller.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ spec:
# The shell image must be root in order to create directories and copy files to PVCs.
# gcr.io/distroless/base:debug as of Apirl 17, 2021
# image shall not contains tag, so it will be supported on a runtime like cri-o
"-shell-image", "gcr.io/distroless/base@sha256:aa4fd987555ea10e1a4ec8765da8158b5ffdfef1e72da512c7ede509bc9966c4"
"-shell-image", "gcr.io/distroless/base@sha256:aa4fd987555ea10e1a4ec8765da8158b5ffdfef1e72da512c7ede509bc9966c4",
# for script mode to work with windows we need a powershell image
# pinning to nanoserver tag as of July 15 2021
"-shell-image-win", "mcr.microsoft.com/powershell:nanoserver@sha256:b6d5ff841b78bdf2dfed7550000fd4f3437385b8fa686ec0f010be24777654d6"
]
volumeMounts:
- name: config-logging
Expand Down
46 changes: 46 additions & 0 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ weight: 200
- [Defining `Steps`](#defining-steps)
- [Reserved directories](#reserved-directories)
- [Running scripts within `Steps`](#running-scripts-within-steps)
- [Windows Scripts](#windows-scripts)
- [Specifying a timeout](#specifying-a-timeout)
- [Specifying `onError` for a `step`](#specifying-onerror-for-a-step)
- [Accessing Step's `exitCode` in subsequent `Steps`](#accessing-steps-exitcode-in-subsequent-steps)
Expand Down Expand Up @@ -265,6 +266,51 @@ steps:
#!/usr/bin/env bash
/bin/my-binary
```

##### Windows scripts

Scripts in tasks that will eventually run on windows nodes need a custom shebang line, so that Tekton knows how to run the script. The format of the shebang line is:

`#!win <interpreter command> <args>`

Unlike linux, we need to specify how to interpret the script file which is generated by Tekton. The example below shows how to execute a powershell script:

```yaml
steps:
- image: mcr.microsoft.com/windows/servercore:1809
script: |
#!win powershell.exe -File
echo 'Hello from PowerShell'
```

Microsoft provide `powershell` images, which contain Powershell Core (which is slightly different from powershell found in standard windows images). The example below shows how to use these images:
```yaml
steps:
- image: mcr.microsoft.com/powershell:nanoserver
script: |
#!win pwsh.exe -File
echo 'Hello from PowerShell Core'
```

As can be seen the command is different. The windows shebang can be used for any interpreter, as long as it exists in the image and can interpret commands from a file. The example below executes a Python script:
```yaml
steps:
- image: python
script: |
#!win python
print("Hello from Python!")
```
Note that other than the `#!win` shebang the example is identical to the earlier linux example.

Finally, if no interpreter is specified on the `#!win` line then the script will be treated as a windows `.cmd` file which will be excecuted. The example below shows this:
```yaml
steps:
- image: mcr.microsoft.com/powershell:lts-nanoserver-1809
script: |
#!win
echo Hello from the default cmd file
```

#### Specifying a timeout

A `Step` can specify a `timeout` field.
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/pipeline/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type Images struct {
KubeconfigWriterImage string
// ShellImage is the container image containing bash shell.
ShellImage string
// ShellImageWin is the container image containing powershell.
ShellImageWin string
// GsutilImage is the container image containing gsutil.
GsutilImage string
// PRImage is the container image that we use to implement the PR source step.
Expand All @@ -55,6 +57,7 @@ func (i Images) Validate() error {
{i.GitImage, "git"},
{i.KubeconfigWriterImage, "kubeconfig-writer"},
{i.ShellImage, "shell"},
{i.ShellImageWin, "windows-shell"},
{i.GsutilImage, "gsutil"},
{i.PRImage, "pr"},
{i.ImageDigestExporterImage, "imagedigest-exporter"},
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/pipeline/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestValidate(t *testing.T) {
GitImage: "set",
KubeconfigWriterImage: "set",
ShellImage: "set",
ShellImageWin: "set",
GsutilImage: "set",
PRImage: "set",
ImageDigestExporterImage: "set",
Expand All @@ -27,6 +28,7 @@ func TestValidate(t *testing.T) {
GitImage: "", // unset!
KubeconfigWriterImage: "set",
ShellImage: "", // unset!
ShellImageWin: "set",
GsutilImage: "set",
PRImage: "", // unset!
ImageDigestExporterImage: "set",
Expand Down
7 changes: 7 additions & 0 deletions pkg/apis/pipeline/v1beta1/task_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ func validateStep(ctx context.Context, s Step, names sets.String) (errs *apis.Fi
})
}
}

if s.Script != "" {
cleaned := strings.TrimSpace(s.Script)
if strings.HasPrefix(cleaned, "#!win") {
errs = errs.Also(ValidateEnabledAPIFields(ctx, "windows script support", config.AlphaAPIFields).ViaField("script"))
}
}
return errs
}

Expand Down
13 changes: 13 additions & 0 deletions pkg/apis/pipeline/v1beta1/task_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,19 @@ func TestIncompatibleAPIVersions(t *testing.T) {
},
}},
},
}, {
name: "windows script support requires alpha",
requiredVersion: "alpha",
spec: v1beta1.TaskSpec{
Steps: []v1beta1.Step{{
Container: corev1.Container{
Image: "my-image",
},
Script: `
#!win powershell -File
script-1`,
}},
},
}}
versions := []string{"alpha", "stable"}
for _, tt := range tests {
Expand Down
4 changes: 2 additions & 2 deletions pkg/pod/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ func (b *Builder) Build(ctx context.Context, taskRun *v1beta1.TaskRun, taskSpec
// Convert any steps with Script to command+args.
// If any are found, append an init container to initialize scripts.
if alphaAPIEnabled {
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, steps, taskSpec.Sidecars, taskRun.Spec.Debug)
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, b.Images.ShellImageWin, steps, taskSpec.Sidecars, taskRun.Spec.Debug)
} else {
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, steps, taskSpec.Sidecars, nil)
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, "", steps, taskSpec.Sidecars, nil)
}
if scriptsInit != nil {
initContainers = append(initContainers, *scriptsInit)
Expand Down
106 changes: 89 additions & 17 deletions pkg/pod/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,28 @@ var (
)

// convertScripts converts any steps and sidecars that specify a Script field into a normal Container.
//
// It does this by prepending a container that writes specified Script bodies
// to executable files in a shared volumeMount, then produces Containers that
// simply run those executable files.
func convertScripts(shellImage string, steps []v1beta1.Step, sidecars []v1beta1.Sidecar, debugConfig *v1beta1.TaskRunDebug) (*corev1.Container, []corev1.Container, []corev1.Container) {
func convertScripts(shellImageLinux string, shellImageWin string, steps []v1beta1.Step, sidecars []v1beta1.Sidecar, debugConfig *v1beta1.TaskRunDebug) (*corev1.Container, []corev1.Container, []corev1.Container) {
placeScripts := false
// Place scripts is an init container used for creating scripts in the
// /tekton/scripts directory which would be later used by the step containers
// as a Command
requiresWindows := checkWindowsRequirement(steps, sidecars)

shellImage := shellImageLinux
shellCommand := "sh"
shellArg := "-c"
// Set windows variants for Image, Command and Args
if requiresWindows {
shellImage = shellImageWin
shellCommand = "pwsh"
shellArg = "-Command"
}

placeScriptsInit := corev1.Container{
Name: "place-scripts",
Image: shellImage,
Command: []string{"sh"},
Args: []string{"-c", ""},
Command: []string{shellCommand},
Args: []string{shellArg, ""},
VolumeMounts: []corev1.VolumeMount{writeScriptsVolumeMount, toolsMount},
}

Expand Down Expand Up @@ -131,6 +139,7 @@ func convertListOfSteps(steps []v1beta1.Step, initContainer *corev1.Container, p
// The shebang must be the first non-empty line.
cleaned := strings.TrimSpace(s.Script)
hasShebang := strings.HasPrefix(cleaned, "#!")
requiresWindows := strings.HasPrefix(cleaned, "#!win")

script := s.Script
if !hasShebang {
Expand All @@ -141,26 +150,40 @@ func convertListOfSteps(steps []v1beta1.Step, initContainer *corev1.Container, p
// non-nil init container.
*placeScripts = true

script = encodeScript(script)

// Append to the place-scripts script to place the
// script file in a known location in the scripts volume.
scriptFile := filepath.Join(scriptsDir, names.SimpleNameGenerator.RestrictLengthWithRandomSuffix(fmt.Sprintf("%s-%d", namePrefix, i)))
heredoc := "_EOF_" // underscores because base64 doesnt include them in its alphabet
initContainer.Args[1] += fmt.Sprintf(`scriptfile="%s"
if requiresWindows {
command, args, script, scriptFile := extractWindowsScriptComponents(script, scriptFile)
initContainer.Args[1] += fmt.Sprintf(`@"
%s
"@ | Out-File -FilePath %s
`, script, scriptFile)

steps[i].Command = command
// Append existing args field to end of derived args
args = append(args, steps[i].Args...)
steps[i].Args = args
} else {
// Only encode the script for linux scripts
// The decode-script subcommand of the entrypoint does not work under windows
script = encodeScript(script)
heredoc := "_EOF_" // underscores because base64 doesnt include them in its alphabet
initContainer.Args[1] += fmt.Sprintf(`scriptfile="%s"
touch ${scriptfile} && chmod +x ${scriptfile}
cat > ${scriptfile} << '%s'
%s
%s
/tekton/tools/entrypoint decode-script "${scriptfile}"
`, scriptFile, heredoc, script, heredoc)

// Set the command to execute the correct script in the mounted
// volume.
// A previous merge with stepTemplate may have populated
// Command and Args, even though this is not normally valid, so
// we'll clear out the Args and overwrite Command.
steps[i].Command = []string{scriptFile}
// Set the command to execute the correct script in the mounted
// volume.
// A previous merge with stepTemplate may have populated
// Command and Args, even though this is not normally valid, so
// we'll clear out the Args and overwrite Command.
steps[i].Command = []string{scriptFile}
}
steps[i].VolumeMounts = append(steps[i].VolumeMounts, scriptsVolumeMount)

// Add debug mounts if breakpoints are present
Expand Down Expand Up @@ -207,3 +230,52 @@ cat > ${scriptfile} << '%s'
func encodeScript(script string) string {
return base64.StdEncoding.EncodeToString([]byte(script))
}

func checkWindowsRequirement(steps []v1beta1.Step, sidecars []v1beta1.Sidecar) bool {
// Detect windows shebangs
for _, step := range steps {
cleaned := strings.TrimSpace(step.Script)
if strings.HasPrefix(cleaned, "#!win") {
return true
}
}
// If no step needs windows, then check sidecars to be sure
for _, sidecar := range sidecars {
cleaned := strings.TrimSpace(sidecar.Script)
if strings.HasPrefix(cleaned, "#!win") {
return true
}
}
return false
}

func extractWindowsScriptComponents(script string, fileName string) ([]string, []string, string, string) {
// Set the command to execute the correct script in the mounted volume.
shebangLine := strings.Split(script, "\n")[0]
splitLine := strings.Split(shebangLine, " ")
var command, args []string
if len(splitLine) > 1 {
strippedCommand := splitLine[1:]
command = strippedCommand[0:1]
// Handle legacy powershell limitation
if strings.HasPrefix(command[0], "powershell") {
fileName += ".ps1"
}
if len(strippedCommand) > 1 {
args = strippedCommand[1:]
args = append(args, fileName)
} else {
args = []string{fileName}
}
} else {
// If no interpreter is specified then strip the shebang and
// create a .cmd file
fileName += ".cmd"
commandLines := strings.Split(script, "\n")[1:]
script = strings.Join(commandLines, "\n")
command = []string{fileName}
args = []string{}
}

return command, args, script, fileName
}
Loading

0 comments on commit 25f3175

Please sign in to comment.