Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7a95aa0
Fix service deployment status UI
arjunkomath Jul 20, 2026
eb84adc
Merge pull request #175 from techulus/fix/service-header-deployment-p…
arjunkomath Jul 20, 2026
1a217b9
Rebuild when Dockerfile path changes
techulus-agent Jul 20, 2026
a130910
Fix staticcheck error messages
techulus-agent Jul 20, 2026
449bf6e
Merge pull request #177 from techulus/fix/dockerfile-path-rebuild
arjunkomath Jul 20, 2026
4ee1d6a
Add public API and migrate CLI
arjunkomath Jul 21, 2026
01a7749
Merge pull request #178 from techulus/feat/public-api-cli
arjunkomath Jul 21, 2026
f224f41
Fail closed on destructive schema changes
ampagent Jul 21, 2026
2ca2ece
Merge pull request #179 from techulus/fix/fail-closed-schema-sync
arjunkomath Jul 21, 2026
38dfc7d
Add AWS KMS BYOK support
techulus-agent Jul 21, 2026
5adc7b3
Document cross-account KMS access
techulus-agent Jul 21, 2026
687883f
Use Blacksmith runners for all CI jobs
ampagent Jul 21, 2026
cebfeed
Avoid redundant React Doctor runs
ampagent Jul 21, 2026
0e13664
Merge pull request #181 from techulus/chore/blacksmith-ci-runners
arjunkomath Jul 21, 2026
9bcdeea
Inline agent encryption key encoding
techulus-agent Jul 21, 2026
050ddac
Clarify agent key encoding
techulus-agent Jul 21, 2026
b4a480c
Clarify awaited key resolution
techulus-agent Jul 21, 2026
91b6d67
Merge pull request #180 from techulus/feat/aws-kms-byok
arjunkomath Jul 21, 2026
f0440fb
fix(cli): add projects list command
ampagent Jul 21, 2026
1f351f3
Merge pull request #182 from techulus/fix/cli-list-projects
arjunkomath Jul 21, 2026
b88e2dd
Format CLI resource output for humans
techulus-agent Jul 21, 2026
d7376f5
Merge pull request #183 from techulus/fix/cli-human-output-formatting
arjunkomath Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/agent-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ concurrency:

jobs:
test:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
defaults:
run:
working-directory: agent
Expand Down Expand Up @@ -49,7 +49,7 @@ jobs:
run: ./.bin/staticcheck ./...

build:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
matrix:
include:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/agent-tip-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions:

jobs:
agent:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
matrix:
include:
Expand Down Expand Up @@ -50,7 +50,7 @@ jobs:

release:
needs: agent
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ concurrency:

jobs:
test:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
defaults:
run:
working-directory: cli
Expand Down Expand Up @@ -49,7 +49,7 @@ jobs:
run: ./.bin/staticcheck ./...

build:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
matrix:
include:
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ name: React Doctor

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened]
branches: [main]
paths:
- "web/**"
- ".github/workflows/react-doctor.yml"
push:
branches: [main]
paths:
- "web/**"
- ".github/workflows/react-doctor.yml"

permissions:
contents: read
Expand All @@ -18,7 +25,7 @@ concurrency:

jobs:
react-doctor:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v5
with:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release'))
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: release-pr-maintain
cancel-in-progress: false
Expand Down Expand Up @@ -195,7 +195,7 @@ jobs:
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'release' &&
startsWith(github.event.pull_request.head.ref, 'automation/release/')
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: release-tag-${{ github.event.pull_request.merge_commit_sha }}
cancel-in-progress: false
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ jobs:
homebrew:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: homebrew-tap
cancel-in-progress: false
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/updater-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ concurrency:

jobs:
test:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
defaults:
run:
working-directory: deployment/updater
Expand Down Expand Up @@ -51,7 +51,7 @@ jobs:
run: ./.bin/staticcheck ./...

build:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
matrix:
include:
Expand Down Expand Up @@ -83,7 +83,7 @@ jobs:
go build -o dist/control-plane-updater-${{ matrix.goos }}-${{ matrix.goarch }} .

image:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
matrix:
platform:
Expand Down
139 changes: 111 additions & 28 deletions agent/internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,17 @@ type Builder struct {
const (
staleBuildDirAge = 1 * time.Hour
staleTempArtifactAge = 24 * time.Hour
dockerfilePathKey = "TECHULUS_DOCKERFILE_PATH"
)

type dockerfileConfig struct {
directory string
filename string
found bool
}

var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`)
var windowsAbsoluteRootPattern = regexp.MustCompile(`^[A-Za-z]:[\\/]`)

func NewBuilder(dataDir string, logSender LogSender) *Builder {
return &Builder{
Expand Down Expand Up @@ -217,32 +225,17 @@ func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir
}

func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir string) error {
contextDir := buildDir
contextDir, err := resolveBuildContext(buildDir, config.RootDir)
if err != nil {
return err
}
if config.RootDir != "" {
contextDir = filepath.Join(buildDir, config.RootDir)
if _, err := os.Stat(contextDir); err != nil {
return fmt.Errorf("root directory %s does not exist: %w", config.RootDir, err)
}
b.sendLog(config, fmt.Sprintf("Using root directory: %s", config.RootDir))
}

dockerfileRelPath := "Dockerfile"
hasDockerfile := false
forcedDockerfile := false
if customPath := config.Secrets["TECHULUS_DOCKERFILE_PATH"]; customPath != "" {
if !filepath.IsLocal(customPath) {
return fmt.Errorf("TECHULUS_DOCKERFILE_PATH must be a relative path within the build context, got %q", customPath)
}
resolved := filepath.Join(contextDir, customPath)
if info, err := os.Stat(resolved); err != nil || info.IsDir() {
b.sendLog(config, fmt.Sprintf("TECHULUS_DOCKERFILE_PATH is set but %s was not found in the repository", customPath))
return fmt.Errorf("TECHULUS_DOCKERFILE_PATH %q not found in build context", customPath)
}
dockerfileRelPath = filepath.Clean(customPath)
hasDockerfile = true
forcedDockerfile = true
} else if _, err := os.Stat(filepath.Join(contextDir, "Dockerfile")); err == nil {
hasDockerfile = true
dockerfile, err := resolveDockerfile(contextDir, config.Secrets)
if err != nil {
return err
}

buildkitAddr := os.Getenv("BUILDKIT_HOST")
Expand All @@ -253,6 +246,9 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str
var secretArgs []string
var secretEnv []string
for key, value := range config.Secrets {
if key == dockerfilePathKey {
continue
}
secretArgs = append(secretArgs, "--secret", fmt.Sprintf("id=%s,env=%s", key, key))
secretEnv = append(secretEnv, fmt.Sprintf("%s=%s", key, value))
}
Expand All @@ -266,10 +262,10 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str
archImageUri := config.ImageURI + "-" + arch
archOutputFlag := fmt.Sprintf("type=image,name=%s,push=true,registry.insecure=true", archImageUri)

if hasDockerfile {
log.Printf("[build:%s] building with Dockerfile %s via buildctl for %s", truncateStr(config.BuildID, 8), dockerfileRelPath, platform)
if forcedDockerfile {
b.sendLog(config, fmt.Sprintf("Using Dockerfile %s (from TECHULUS_DOCKERFILE_PATH)", dockerfileRelPath))
if dockerfile.found {
log.Printf("[build:%s] building with Dockerfile via buildctl for %s", truncateStr(config.BuildID, 8), platform)
if configuredPath := strings.TrimSpace(config.Secrets[dockerfilePathKey]); configuredPath != "" {
b.sendLog(config, fmt.Sprintf("Using Dockerfile: %s", configuredPath))
} else {
b.sendLog(config, "Using existing Dockerfile")
}
Expand All @@ -280,8 +276,8 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str
"build",
"--frontend", "dockerfile.v0",
"--local", "context=.",
"--local", fmt.Sprintf("dockerfile=%s", filepath.Dir(dockerfileRelPath)),
"--opt", fmt.Sprintf("filename=%s", filepath.Base(dockerfileRelPath)),
"--local", fmt.Sprintf("dockerfile=%s", dockerfile.directory),
"--opt", fmt.Sprintf("filename=%s", dockerfile.filename),
"--opt", fmt.Sprintf("platform=%s", platform),
"--output", archOutputFlag,
}
Expand Down Expand Up @@ -350,6 +346,93 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str
return nil
}

func resolveBuildContext(buildDir, rootDir string) (string, error) {
if rootDir == "" {
return buildDir, nil
}

if windowsAbsoluteRootPattern.MatchString(rootDir) {
return "", fmt.Errorf("root directory %s must be relative to and inside the cloned repository", rootDir)
}
normalizedRoot := strings.ReplaceAll(rootDir, "\\", "/")
cleanedRoot := filepath.Clean(filepath.FromSlash(normalizedRoot))
if filepath.IsAbs(cleanedRoot) || cleanedRoot == ".." || strings.HasPrefix(cleanedRoot, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("root directory %s must be relative to and inside the cloned repository", rootDir)
}

contextDir := filepath.Join(buildDir, cleanedRoot)
info, err := os.Stat(contextDir)
if err != nil {
return "", fmt.Errorf("root directory %s does not exist: %w", rootDir, err)
}
if !info.IsDir() {
return "", fmt.Errorf("root directory %s is not a directory", rootDir)
}

resolvedBuildDir, err := filepath.EvalSymlinks(buildDir)
if err != nil {
return "", fmt.Errorf("failed to resolve cloned repository directory: %w", err)
}
resolvedContextDir, err := filepath.EvalSymlinks(contextDir)
if err != nil {
return "", fmt.Errorf("failed to resolve root directory %s: %w", rootDir, err)
}
relativePath, err := filepath.Rel(resolvedBuildDir, resolvedContextDir)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("root directory %s must resolve inside the cloned repository", rootDir)
}

return contextDir, nil
}

func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfileConfig, error) {
configuredPath, configured := secrets[dockerfilePathKey]
configuredPath = strings.TrimSpace(configuredPath)
if configured && configuredPath == "" {
return dockerfileConfig{}, fmt.Errorf("%s cannot be empty", dockerfilePathKey)
}

if !configured {
if _, err := os.Stat(filepath.Join(contextDir, "Dockerfile")); err == nil {
return dockerfileConfig{directory: ".", filename: "Dockerfile", found: true}, nil
} else if !os.IsNotExist(err) {
return dockerfileConfig{}, fmt.Errorf("failed to inspect Dockerfile: %w", err)
}
return dockerfileConfig{}, nil
}

cleanedPath := filepath.Clean(configuredPath)
if filepath.IsAbs(cleanedPath) || cleanedPath == ".." || strings.HasPrefix(cleanedPath, ".."+string(filepath.Separator)) {
return dockerfileConfig{}, fmt.Errorf("%s must be relative to the service root directory", dockerfilePathKey)
}

info, err := os.Stat(filepath.Join(contextDir, cleanedPath))
if err != nil {
return dockerfileConfig{}, fmt.Errorf("dockerfile %s does not exist: %w", configuredPath, err)
}
if info.IsDir() {
return dockerfileConfig{}, fmt.Errorf("dockerfile path %s is a directory", configuredPath)
}
resolvedContextDir, err := filepath.EvalSymlinks(contextDir)
if err != nil {
return dockerfileConfig{}, fmt.Errorf("failed to resolve service root directory: %w", err)
}
resolvedPath, err := filepath.EvalSymlinks(filepath.Join(contextDir, cleanedPath))
if err != nil {
return dockerfileConfig{}, fmt.Errorf("failed to resolve Dockerfile %s: %w", configuredPath, err)
}
relativePath, err := filepath.Rel(resolvedContextDir, resolvedPath)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
return dockerfileConfig{}, fmt.Errorf("%s must resolve inside the service root directory", dockerfilePathKey)
}

return dockerfileConfig{
directory: filepath.Dir(cleanedPath),
filename: filepath.Base(cleanedPath),
found: true,
}, nil
}

func (b *Builder) runCommand(cmd *exec.Cmd, config *Config) (string, error) {
output, err := cmd.CombinedOutput()
outputStr := string(output)
Expand Down
Loading
Loading