Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ONPREM-1829] [HACKWEEK] Add initial support for Windows containers #96

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Next Next commit
Support Windows containers
  • Loading branch information
christian-stephen committed Feb 21, 2025
commit daf5af56cb4180aeffe6aa0e2f676543c725155b
28 changes: 25 additions & 3 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -19,6 +19,11 @@ executors:
docker:
- image: cimg/go:1.24
resource_class: circleci-runner/rum-large
windows:
machine:
image: windows-server-2022-gui:current
shell: bash.exe -login
resource_class: windows.xlarge
ccc:
docker:
- image: circleci/command-convenience:0.1
@@ -39,7 +44,10 @@ workflows:
- equal: [ false, << pipeline.parameters.trigger_nightly_workflow >> ]
jobs:
- lint
- test
- test:
matrix:
parameters:
os: [ go, windows ]
- build
- scan:
context: [ org-global ]
@@ -110,12 +118,26 @@ jobs:
- notify_failing_main

test:
executor: go
parameters:
os:
type: string
executor: << parameters.os >>
steps:
- setup
- when:
condition:
equal: [ << parameters.os >>, "windows" ]
steps:
- run:
name: "Install GCC"
command: |
choco install mingw -y
echo 'export PATH="$PATH:/c/ProgramData/mingw64/mingw64/bin"' >> ~/.bash_profile
source ~/.bash_profile
gcc -v
- with-go-cache:
steps:
- run: ./do test ./... -count 3
- run: ./do test ./...
- notify_failing_main

build:
13 changes: 11 additions & 2 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ package acceptance
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"

@@ -69,8 +70,16 @@ func runTests(m *testing.M) (int, error) {

// A little hack to get around limitations of the test runner on positional arguments
func createRunTaskScript() error {
script := "#!/bin/bash\nexec " + orchestratorTestBinary + " run-task"
scriptPath := binariesPath + "/orchestratorRunTask.sh"
var script string
var scriptPath string

if runtime.GOOS == "windows" {
script = "@echo off\n" + orchestratorTestBinary + " run-task"
scriptPath = filepath.Join(binariesPath, "orchestratorRunTask.bat")
} else {
script = "#!/bin/bash\nexec " + orchestratorTestBinary + " run-task"
scriptPath = filepath.Join(binariesPath, "orchestratorRunTask.sh")
}

if err := os.WriteFile(scriptPath, []byte(script), 0750); err != nil { //nolint:gosec
return err
15 changes: 8 additions & 7 deletions acceptance/init_test.go
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package acceptance

import (
"os"
"path/filepath"
"testing"
"time"

@@ -13,11 +14,11 @@ import (
func TestInit(t *testing.T) {
srcDir := createMockSourceFiles(t)
destDir := t.TempDir()
orchSrc := srcDir + "/orchestrator"
orchDest := destDir + "/orchestrator"
agentSrc := srcDir + "/circleci-agent"
agentDest := destDir + "/circleci-agent"
circleciDest := destDir + "/circleci"
orchSrc := filepath.Join(srcDir, "orchestrator")
orchDest := filepath.Join(destDir, "orchestrator")
agentSrc := filepath.Join(srcDir, "circleci-agent")
agentDest := filepath.Join(destDir, "circleci-agent")
circleciDest := filepath.Join(destDir, "circleci")

r := runner.New(
"SOURCE="+srcDir,
@@ -53,10 +54,10 @@ func createMockSourceFiles(t *testing.T) string {

srcDir := t.TempDir()

err := os.WriteFile(srcDir+"/orchestrator", []byte("mock orchestrator data"), 0600)
err := os.WriteFile(filepath.Join(srcDir, "orchestrator"), []byte("mock orchestrator data"), 0600)
assert.NilError(t, err)

err = os.WriteFile(srcDir+"/circleci-agent", []byte("mock agent data"), 0600)
err = os.WriteFile(filepath.Join(srcDir, "circleci-agent"), []byte("mock agent data"), 0600)
assert.NilError(t, err)

return srcDir
11 changes: 7 additions & 4 deletions acceptance/task_test.go
Original file line number Diff line number Diff line change
@@ -3,6 +3,8 @@ package acceptance
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"

@@ -11,18 +13,18 @@ import (
)

func TestRunTask(t *testing.T) {
readinessFilePath := t.TempDir() + "/ready"
readinessFilePath := filepath.Join(t.TempDir(), "ready")
goodConfig := fmt.Sprintf(`
{
"cmd": [],
"enable_unsafe_retries": false,
"token": "testtoken",
"readiness_file_path": "%s",
"readiness_file_path": "%v",
"task_agent_path": "%v",
"runner_api_base_url": "https://runner.circleci.com",
"allocation": "testallocation",
"max_run_time": 60000000000
}`, readinessFilePath, taskAgentBinary)
}`, strings.ReplaceAll(readinessFilePath, `\`, `\\`), strings.ReplaceAll(taskAgentBinary, `\`, `\\`))

r := runner.New(
"CIRCLECI_GOAT_SHUTDOWN_DELAY=10s",
@@ -37,7 +39,8 @@ func TestRunTask(t *testing.T) {
})

go func() {
_, err := os.Create(readinessFilePath) //nolint:gosec
f, err := os.Create(readinessFilePath) //nolint:gosec
defer func() { assert.NilError(t, f.Close()) }()
assert.NilError(t, err)
}()

5 changes: 5 additions & 0 deletions cmd/orchestrator/help_test.go
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package main

import (
"bytes"
"runtime"
"testing"

"github.com/alecthomas/kong"
@@ -11,6 +12,10 @@ import (
)

func TestHelp(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip()
}

cli := &cli{}

var tests = []struct {
2 changes: 1 addition & 1 deletion do
Original file line number Diff line number Diff line change
@@ -98,7 +98,7 @@ help_test="Run the tests"
test() {
mkdir -p "${reportDir}"
# -count=1 is used to forcibly disable test result caching
go tool gotestsum --junitfile="${reportDir}/junit.xml" -- -race -count=1 "${@:-./...}"
CGO_ENABLED=1 go tool gotestsum --junitfile="${reportDir}/junit.xml" -- -race -count=1 "${@:-./...}"
}

# This variable is used, but shellcheck can't tell.
22 changes: 14 additions & 8 deletions init/init_test.go
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ package init

import (
"os"
"path/filepath"
"runtime"
"testing"

"gotest.tools/v3/assert"
@@ -11,11 +13,11 @@ import (
func TestRun(t *testing.T) {
srcDir := createMockSourceFiles(t)
destDir := t.TempDir()
orchSrc := srcDir + "/orchestrator"
orchDest := destDir + "/orchestrator"
agentSrc := srcDir + "/circleci-agent"
agentDest := destDir + "/circleci-agent"
circleciDest := destDir + "/circleci"
orchSrc := filepath.Join(srcDir, "orchestrator")
orchDest := filepath.Join(destDir, "orchestrator")
agentSrc := filepath.Join(srcDir, "circleci-agent")
agentDest := filepath.Join(destDir, "circleci-agent")
circleciDest := filepath.Join(destDir, "circleci")

t.Run("Copy files and create symlink", func(t *testing.T) {
err := Run(srcDir, destDir)
@@ -31,7 +33,11 @@ func TestRun(t *testing.T) {

t.Run("Fail when source files not present", func(t *testing.T) {
err := Run(srcDir, "non-existent-dir")
assert.Check(t, cmp.ErrorContains(err, "no such file or directory"))
if runtime.GOOS == "windows" {
assert.Check(t, cmp.ErrorContains(err, "The system cannot find the path specified"))
} else {
assert.Check(t, cmp.ErrorContains(err, "no such file or directory"))
}
})
}

@@ -41,10 +47,10 @@ func createMockSourceFiles(t *testing.T) string {

srcDir := t.TempDir()

err := os.WriteFile(srcDir+"/orchestrator", []byte("mock orchestrator data"), 0600)
err := os.WriteFile(filepath.Join(srcDir, "orchestrator"), []byte("mock orchestrator data"), 0600)
assert.NilError(t, err)

err = os.WriteFile(srcDir+"/circleci-agent", []byte("mock agent data"), 0600)
err = os.WriteFile(filepath.Join(srcDir, "circleci-agent"), []byte("mock agent data"), 0600)
assert.NilError(t, err)

return srcDir
4 changes: 3 additions & 1 deletion task/cmd/cmd.go → task/cmd/cmd_unix.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !windows

package cmd

import (
@@ -154,7 +156,7 @@ func maybeSwitchUser(ctx context.Context, cmd *exec.Cmd, username string) {

func notifySignals(cmd *exec.Cmd) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
signal.Notify(ch, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGQUIT)

go func() {
for sig := range ch {
2 changes: 2 additions & 0 deletions task/cmd/cmd_test.go → task/cmd/cmd_unix_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !windows

package cmd

import (
129 changes: 129 additions & 0 deletions task/cmd/cmd_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cmd

import (
"context"
"fmt"
"github.com/circleci/ex/o11y"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"sync/atomic"
)

type Command struct {
cmd *exec.Cmd
stderrSaver *prefixSuffixSaver
isStarted atomic.Bool
isDone atomic.Bool
forwardSignals bool
}

func New(ctx context.Context, cmd []string, forwardSignals bool, user string, env ...string) Command {
s := &prefixSuffixSaver{N: 160}
return Command{
cmd: newCmd(ctx, cmd, user, s, env...),
stderrSaver: s,
forwardSignals: forwardSignals,
}
}

func (c *Command) Start() error {
cmd := c.cmd

if err := cmd.Start(); err != nil {
return err
}

if cmd.Process == nil {
return fmt.Errorf("no underlying process")
}

if c.forwardSignals {
notifySignals(cmd)
}

c.isStarted.Store(true)

return nil
}

func (c *Command) StartWithStdin(b []byte) error {
w, err := c.cmd.StdinPipe()

if err != nil {
return fmt.Errorf("unexpected error on stdin pipe: %w", err)
}
defer func() {
_ = w.Close()
}()

_, err = w.Write(b)
if err != nil {
return fmt.Errorf("failed to write to stdin pipe: %w", err)
}

return c.Start()
}

func (c *Command) Wait() error {
cmd := c.cmd
defer func() {
_ = cmd.Cancel()
}()

err := cmd.Wait()
c.isDone.Store(cmd.ProcessState != nil)
if err != nil {
stderr := c.stderrSaver.Bytes()
if len(stderr) > 0 {
return fmt.Errorf("%w: %s", err, string(stderr))
}
}
return err
}

func (c *Command) IsRunning() (bool, error) {
if !c.isStarted.Load() {
return false, nil
}

return !c.isDone.Load(), nil
}

func newCmd(ctx context.Context, argv []string, user string, stderrSaver *prefixSuffixSaver, env ...string) *exec.Cmd {
//#nosec:G204 // this is intentionally setting up a command
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)

for _, env := range os.Environ() {
if strings.HasPrefix(env, "CIRCLECI_GOAT") {
// Prevent internal configuration from being injected in the command environment
continue
}
cmd.Env = append(cmd.Env, env)
}
if env != nil {
cmd.Env = append(cmd.Env, env...)
}

cmd.Stdout = os.Stdout
cmd.Stderr = io.MultiWriter(os.Stderr, stderrSaver)

if user != "" {
o11y.Log(ctx, "switching users is unsupported on windows", o11y.Field("user", user))
}

return cmd
}

func notifySignals(cmd *exec.Cmd) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)

go func() {
for range ch {
_ = cmd.Process.Kill()
}
}()
}
Loading
Oops, something went wrong.