Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc09351185 | |||
| 405877ee27 | |||
| 5af2b1d660 | |||
| 075d8bbc5f | |||
| a2738f56c4 | |||
| df3f980fa0 | |||
| 7e26490b5f | |||
| 8ca5ffd0fc | |||
| 5c07fafa18 | |||
| fb89c30d91 | |||
| b8f766de03 | |||
| 566145d45a | |||
| 4a97cb1ea2 | |||
| e55dfed716 | |||
| 1b71e0515f | |||
| d7896e5287 | |||
| fed24b93e9 | |||
| 3be86e6daf | |||
| 437908662f | |||
| 1c2843a39d | |||
| 82dfe7a941 | |||
| 699196f368 | |||
| 61c97c847c | |||
| 6f04b22df0 | |||
| eadf2cc2c5 | |||
| 93ac4bda66 | |||
| 454040fdd1 | |||
| c95bd73e42 | |||
| ecdba833d9 | |||
| 6e65eadaa5 |
@@ -872,3 +872,70 @@ scheduler.Schedule(spec, nodes) → emitter.Render(unit) → sshpush.Deploy(targ
|
|||||||
- IPv6 `net.JoinHostPort` in all SSH dial paths.
|
- IPv6 `net.JoinHostPort` in all SSH dial paths.
|
||||||
- Explicit timeouts on all SSH commands.
|
- Explicit timeouts on all SSH commands.
|
||||||
- Root SIGINT/SIGTERM handler for clean exit on non-watch commands.
|
- Root SIGINT/SIGTERM handler for clean exit on non-watch commands.
|
||||||
|
|
||||||
|
## v0.14 Deltas — Ingress Bootstrap Completeness (R-024)
|
||||||
|
|
||||||
|
### R-024: Traefik as Podman Container
|
||||||
|
|
||||||
|
Traefik runs exclusively as a podman container, deployed from the
|
||||||
|
custom `orca-traefik` image (published per release via `Dockerfile.traefik`
|
||||||
|
+ `scripts/release.sh` + `.coreci.yml container-publish-traefik`).
|
||||||
|
|
||||||
|
The v0.13 binary+systemd install (`internal/traefik/install.go`) is
|
||||||
|
replaced by an idempotent podman container reconciler
|
||||||
|
(`EnsureTraefikContainerLocal`/`Remote`). The container runs with
|
||||||
|
`--network host`, `--restart=unless-stopped`, and volume mounts for
|
||||||
|
`traefik.yml` (static config), `dynamic` (dynamic config), and
|
||||||
|
`step-ca-root.crt` (future mTLS). No SELinux `:Z` flag.
|
||||||
|
|
||||||
|
### Three Ingress Topologies
|
||||||
|
|
||||||
|
1. **Linux** (`orca init` / `orca node join --type linux`):
|
||||||
|
host → nft DNAT → podman traefik (host network).
|
||||||
|
`internal/ingress/bootstrap.go` → `BootstrapLocalIngress` /
|
||||||
|
`BootstrapRemoteIngress`.
|
||||||
|
|
||||||
|
2. **Proxmox Native** (`--ingress-mode native`, default):
|
||||||
|
PVE host → nft DNAT (target = LXC bridge IP) → LXC
|
||||||
|
(`--features nesting=1,keyctl=1,fuse=1`) → podman traefik.
|
||||||
|
`internal/proxmox/bootstrap.go` → `provisionNativeIngressLXC`.
|
||||||
|
|
||||||
|
3. **Proxmox Floating-IP** (`--ingress-mode floating-ip`):
|
||||||
|
LXC owns the floating IP (`net0 bridge=vmbr0,hwaddr=<mac>,
|
||||||
|
ip=<floating-ip>/<prefix>,gw=<gateway>`) → nft inside LXC →
|
||||||
|
podman traefik. The ingress LXC is registered as a `linux` node
|
||||||
|
(name=`ingress`) so `orca job run` pushes traefik dynamic config.
|
||||||
|
`internal/proxmox/ingress_lxc.go` → `ProvisionIngressLXC`.
|
||||||
|
|
||||||
|
### nft Emitter Changes
|
||||||
|
|
||||||
|
`internal/emitter/nft.go`:
|
||||||
|
- `DNATTarget` field (C-51: validated via `net.ParseIP`). Default
|
||||||
|
`127.0.0.1`; proxmox native uses LXC bridge IP.
|
||||||
|
- `EnableSNAT` field + postrouting masquerade chain: `ip saddr
|
||||||
|
127.0.0.0/8 oifname != "lo" masquerade` (research Topic 1).
|
||||||
|
- Input/forward chain priority shifted from `filter` (=0) to `-10`
|
||||||
|
(research Topic 2: pve-firewall coexistence — avoids same-priority
|
||||||
|
undefined evaluation order).
|
||||||
|
|
||||||
|
### TLS Model
|
||||||
|
|
||||||
|
v0.14 drops `certResolver: orca` from the dynamic config (traefik v3.3
|
||||||
|
only supports `acme`/`tailscale` resolvers, not CA-file-based). The
|
||||||
|
dynamic config emits `tls: {}` (traefik default cert). Real mTLS via
|
||||||
|
`tls.certificates` + `tls.options.default.clientAuth.caFiles` is
|
||||||
|
deferred to v0.15 (grill G-003, confidence 0.55 < 0.60).
|
||||||
|
|
||||||
|
### Migration 0009
|
||||||
|
|
||||||
|
`ALTER TABLE nodes ADD COLUMN ingress_mode TEXT NOT NULL DEFAULT '';`
|
||||||
|
Values: `""` (legacy), `"native"`, `"floating-ip"`. `IngressMode` field
|
||||||
|
on `model.Node`.
|
||||||
|
|
||||||
|
### New CLI
|
||||||
|
|
||||||
|
- `orca doctor ingress` — verifies podman container running, nft
|
||||||
|
DNAT+SNAT, dynamic dir, step-ca root CA.
|
||||||
|
- `--ingress-mode` flag on `orca node join --type proxmox`.
|
||||||
|
- `--floating-ip`, `--gateway`, `--mac`, `--net-prefix` flags for
|
||||||
|
floating-IP mode.
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
{
|
{
|
||||||
"phase": 0,
|
"phase": 1,
|
||||||
"stage": "complete",
|
"stage": "complete",
|
||||||
"milestone": "v0.14",
|
"milestone": "v0.16",
|
||||||
"milestone_slug": "ingress-bootstrap",
|
"milestone_slug": "release-binary-fix",
|
||||||
"phase_role": "pre_execution",
|
"phase_role": "execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-10T18:27:00Z",
|
"updated_at": "2026-08-12T23:10:00Z",
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
"previous_milestone": "v0.13",
|
"previous_milestone": "v0.15",
|
||||||
"phases_shipped": ["P0"],
|
"phases_shipped": ["P0", "P1"],
|
||||||
"tags_shipped": ["v0.13.0"],
|
"tags_shipped": ["v0.15.0", "v0.15.1"],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"covered": [],
|
"covered": [183, 184],
|
||||||
"partial": []
|
"partial": []
|
||||||
},
|
|
||||||
"binding_conditions": ["C-50","C-51","C-52","C-53","C-54","C-55","C-56","C-57","C-58","C-59","C-60","C-61"],
|
|
||||||
"load_bearing_rule": "R-024",
|
|
||||||
"ship": {
|
|
||||||
"tag": "v0.13.0",
|
|
||||||
"merged_to_milestone": true,
|
|
||||||
"release_created": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# CLARIFY + RESEARCH + PLAN v0.15: CI Release Pipeline Fix
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| ID | Decision | Rationale | Confidence |
|
||||||
|
|----|----------|-----------|------------|
|
||||||
|
| D-264 | Secret name = `PAT_TOKEN` (not `GITEA_PAT`) | Gitea reserves `GITEA_` prefix for built-in secrets | 1.0 (validated) |
|
||||||
|
| D-265 | Use `tea actions secrets create` CLI | Operator instruction: no API | 1.0 (validated) |
|
||||||
|
| D-266 | Container publishing in Gitea Actions, not CoreCI | CoreCI's podman executor appends `sh -c` which conflicts with kaniko's `/kaniko/executor` entrypoint. Gitea Actions `container:` supports `options: --entrypoint` | 0.95 |
|
||||||
|
| D-267 | kaniko `executor:debug` image | Includes `/bin/sh`; Gitea Actions can override entrypoint to `/bin/sh` then run kaniko via shell | 0.90 |
|
||||||
|
| D-268 | `coreci run` for validate/build/test/release (tarball); Gitea Actions for container publishing | Clean separation: CoreCI owns the pipeline, Gitea Actions owns the trigger + container publish | 0.95 |
|
||||||
|
|
||||||
|
## Research: CoreCI podman executor entrypoint issue
|
||||||
|
|
||||||
|
CoreCI's `internal/runner/podman_executor.go:48-51`:
|
||||||
|
```go
|
||||||
|
args = append(args, image) // e.g. gcr.io/kaniko-project/executor:debug
|
||||||
|
if job.Invoke != "" {
|
||||||
|
args = append(args, "sh", "-c", job.Invoke)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This produces: `podman run ... <image> sh -c "<commands>"`
|
||||||
|
With kaniko:debug (entrypoint `/kaniko/executor`), the actual command is:
|
||||||
|
`/kaniko/executor sh -c "<commands>"` — kaniko fails (sh is not a kaniko flag).
|
||||||
|
|
||||||
|
**Conclusion**: kaniko cannot be used as a CoreCI step image. Container
|
||||||
|
publishing must move to the Gitea Actions workflow, which supports
|
||||||
|
`container: options: --entrypoint /bin/sh` to override the entrypoint.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### Phase 1 (only execution phase)
|
||||||
|
|
||||||
|
**Files to create/modify:**
|
||||||
|
|
||||||
|
1. `.gitea/workflows/release.yml` — Gitea Actions workflow:
|
||||||
|
- `on: push: tags: ['v*']`
|
||||||
|
- Job 1 `ci`: checkout + install Go + install coreci + `coreci run`
|
||||||
|
(executes validate/build/test/release from .coreci.yml)
|
||||||
|
- Job 2 `container-orca`: checkout + kaniko build+push orca image
|
||||||
|
(needs job 1; uses `container: gcr.io/kaniko-project/executor:debug`
|
||||||
|
with `options: --entrypoint /bin/sh`)
|
||||||
|
- Job 3 `container-traefik`: checkout + kaniko build+push orca-traefik image
|
||||||
|
(needs job 1; same kaniko approach)
|
||||||
|
|
||||||
|
2. `.coreci.yml` — remove `container-publish` and `container-publish-traefik`
|
||||||
|
steps (they now live in the Gitea Actions workflow). Keep the
|
||||||
|
`gitea-release` step (tarball + Gitea release).
|
||||||
|
|
||||||
|
3. `scripts/trigger_coreci.sh` — add tag ref handling (or document that
|
||||||
|
Gitea Actions is the trigger; the hook is for branch-push CI only).
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# CLARIFY v0.16: Release Binary Asset Fix
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| ID | Decision | Rationale | Confidence |
|
||||||
|
|----|----------|-----------|------------|
|
||||||
|
| D-269 | Auth the coreci clone via URL embedding | The `actions/checkout@v4` step injects auth only for the orca repo. The bare `git clone https://git.cloudinit.dev/coreci/coreci.git` has no credentials and fails with `fatal: could not read Username`. Embedding `https://cloudinit-bot:${GITEA_TOKEN}@git.cloudinit.dev/...` in the clone URL is the simplest fix — no git credential helper config needed. | 0.95 |
|
||||||
|
| D-270 | Rewrite .coreci.yml to CoreCI native `jobs:` format | CoreCI's `Pipeline` struct (`workflows/pipeline/pipeline.go`) only has `Jobs`/`Services`/`Env` fields. The orca `.coreci.yml` uses `pipelines:`/`steps:`/`image:`/`commands:` which are silently dropped by `yaml.Unmarshal` → empty `Jobs` map → zero jobs execute. `validate()` does not reject empty jobs. | 0.97 (validated against CoreCI source) |
|
||||||
|
| D-271 | No tag-conditional rules in .coreci.yml | The Gitea Actions workflow already gates on `on: push: tags: ['v*']`. Every `coreci run` invocation is already a release run. CoreCI's `cicontext/github.go` doesn't even set `CI_COMMIT_TAG` for Gitea Actions (it maps `GITHUB_REF_NAME` → `CI_COMMIT_BRANCH`). | 0.90 |
|
||||||
|
| D-272 | Use `CI_COMMIT_BRANCH` for tag name | On a tag push, Gitea Actions sets `GITHUB_REF_NAME=v0.15.1`. CoreCI's `github.go` maps this to `CI_COMMIT_BRANCH`. So `CI_COMMIT_BRANCH` contains the tag name on tag pushes. This is the env var to use for version injection in `invoke:` scripts. | 0.90 |
|
||||||
|
| D-273 | Handle duplicate release gracefully | The CIAgent ship workflow creates releases (title+body, no binary) via the Gitea API. The `coreci run` release job runs later (after the Gitea Actions workflow triggers). `tea releases create` fails if the release exists. Fallback: query the release ID by tag and attach assets via the Gitea API `POST /releases/{id}/assets` endpoint. | 0.92 |
|
||||||
|
| D-274 | Shell-friendly jobs (no `apk add`) | The Gitea Actions runner runs the `ci` job inside `docker.gitea.com/runner-images:ubuntu-latest` (ubuntu, not alpine). CoreCI's shell-isolated executor (the likely fallback if podman/docker aren't in the runner container) runs `sh -c <invoke>` directly. `apk add` won't work on ubuntu. Use `curl` (pre-installed) for tool downloads. | 0.85 |
|
||||||
|
| D-275 | `GITEA_TOKEN` via PassThroughEnv | CoreCI's `PassThroughEnv()` forwards env vars not in the deny-list. `GITEA_TOKEN` is not in `systemVars` or `predefinedCIVars`, so it IS forwarded when `IsRunningInCI()` is true (Gitea Actions sets `GITHUB_ACTIONS=true`). The Gitea Actions workflow sets `GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}` in the `coreci run` step env. So `GITEA_TOKEN` is available in job `invoke:` scripts. Belt-and-suspenders: also declare `vars: { GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} }` so CoreCI's secret resolver picks it up via `os.Getenv` fallback. | 0.88 |
|
||||||
|
|
||||||
|
## Research: CoreCI .coreci.yml format (validated against source)
|
||||||
|
|
||||||
|
CoreCI's `Pipeline` struct (`workflows/pipeline/pipeline.go`):
|
||||||
|
```go
|
||||||
|
type Pipeline struct {
|
||||||
|
Jobs map[string]Job `yaml:"jobs"`
|
||||||
|
Services map[string]Service `yaml:"services,omitempty"`
|
||||||
|
Env EnvBlock `yaml:"env,omitempty"`
|
||||||
|
}
|
||||||
|
type Job struct {
|
||||||
|
Needs []string `yaml:"needs,omitempty"`
|
||||||
|
Plugin string `yaml:"plugin,omitempty"`
|
||||||
|
Invoke string `yaml:"invoke,omitempty"`
|
||||||
|
Vars map[string]string `yaml:"vars,omitempty"`
|
||||||
|
MemoryLimitMb int `yaml:"memory_limit_mb,omitempty"`
|
||||||
|
TimeoutMs int `yaml:"timeout_ms,omitempty"`
|
||||||
|
Rules []Rule `yaml:"rules,omitempty"`
|
||||||
|
Tags []string `yaml:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key facts (from `docs/CORECI-YML.md` + source):
|
||||||
|
- `plugin` and `invoke` are mutually exclusive on the same job — BUT the
|
||||||
|
isolated_shell executor checks `invoke` first (line 52) and only falls
|
||||||
|
back to `plugin` if `invoke` is empty. So a job with BOTH `plugin:
|
||||||
|
docker://golang:1.25` AND `invoke: "go build ..."` works: container
|
||||||
|
executors run the invoke inside the container; shell-isolated runs
|
||||||
|
the invoke directly (ignoring the image). This is the correct pattern.
|
||||||
|
- Unknown YAML fields are silently dropped (no strict decode). This is
|
||||||
|
why the current `pipelines:`/`steps:`/`image:`/`commands:` format
|
||||||
|
produces an empty `Jobs` map with no error.
|
||||||
|
- `validate()` does NOT reject empty `Jobs` maps — it only checks for
|
||||||
|
duplicate names and plugin/invoke mutual exclusivity within existing
|
||||||
|
jobs.
|
||||||
|
- Secret interpolation `${{ secrets.KEY }}` works only in job `vars:`
|
||||||
|
values. The resolver (run.go:146-153) checks the local secret store
|
||||||
|
first, then falls back to `os.Getenv(key)`.
|
||||||
|
- Shell `${VAR}` expansion works inside `invoke:` strings at runtime
|
||||||
|
(via `sh -c`), but NOT in YAML field values at parse time.
|
||||||
|
- `env.from_ci` controls which CI vars are injected; if empty, all
|
||||||
|
detected CI vars are merged.
|
||||||
|
|
||||||
|
## Research: Gitea Actions runner environment
|
||||||
|
|
||||||
|
The `ci` job (no `container:` field) runs inside
|
||||||
|
`docker.gitea.com/runner-images:ubuntu-latest`. The `Set up Go` step
|
||||||
|
installs Go 1.25. CoreCI's executor chain is `podman,docker,shell-isolated`.
|
||||||
|
If podman/docker aren't in the runner container, jobs fall back to
|
||||||
|
`shell-isolated` which runs `sh -c <invoke>` directly. Go commands work
|
||||||
|
in shell-isolated mode (Go is on PATH). Tool installation via `go install`
|
||||||
|
works (needs Go + network). `gitleaks` binary download via `curl` works.
|
||||||
|
|
||||||
|
## Research: Gitea Actions CI context (CoreCI detection)
|
||||||
|
|
||||||
|
CoreCI's `cicontext/github.go`:
|
||||||
|
```go
|
||||||
|
func isGitHub() bool { return os.Getenv("GITHUB_ACTIONS") == "true" }
|
||||||
|
func normalizeGitHub() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"CI": "true",
|
||||||
|
"CI_COMMIT_SHA": envOrDefault("GITHUB_SHA", ""),
|
||||||
|
"CI_COMMIT_BRANCH": envOrDefault("GITHUB_REF_NAME", ""),
|
||||||
|
// ... (no CI_COMMIT_TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On a tag push: `GITHUB_REF_NAME=v0.15.1` → `CI_COMMIT_BRANCH=v0.15.1`.
|
||||||
|
`CI_COMMIT_TAG` is NOT set — CoreCI doesn't populate it for Gitea Actions.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### Phase 1 (only execution phase)
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
|
||||||
|
1. `.gitea/workflows/release.yml` — move `GITEA_TOKEN` env to the
|
||||||
|
"Install CoreCI" step and embed it in the clone URL:
|
||||||
|
```yaml
|
||||||
|
- name: Install CoreCI
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 https://cloudinit-bot:${GITEA_TOKEN}@git.cloudinit.dev/coreci/coreci.git /tmp/coreci
|
||||||
|
cd /tmp/coreci
|
||||||
|
CGO_ENABLED=0 go build -tags sqlite_go,embed -o /usr/local/bin/coreci ./cmd/coreci
|
||||||
|
coreci version
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `.coreci.yml` — full rewrite to CoreCI native `jobs:` format:
|
||||||
|
- DAG: `go-vet` → fan-out to `verify-reqs`, `gosec`, `govulncheck`,
|
||||||
|
`gitleaks` → `build` → `test` → `release`
|
||||||
|
- Each job: `plugin: docker://golang:1.25.12` + `invoke: |` (multi-line)
|
||||||
|
- `build` job: version injection via `CI_COMMIT_BRANCH` (tag) +
|
||||||
|
`CI_COMMIT_SHA` + `date` for build time
|
||||||
|
- `release` job: build tarball + SHA256SUMS, install `tea` via curl,
|
||||||
|
create release with assets (fallback to API asset attachment if
|
||||||
|
release exists), verify asset count ≥ 2 (REQ-097 gate C-21)
|
||||||
|
- `GITEA_TOKEN` via `vars: { GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} }`
|
||||||
|
- No tag-conditional rules (workflow already gates on tags)
|
||||||
|
|
||||||
|
3. `scripts/trigger_coreci.sh` — no changes needed (Gitea Actions is the
|
||||||
|
trigger; the hook is for branch-push CI only).
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# PLAN v0.16: Release Binary Asset Fix
|
||||||
|
|
||||||
|
## Milestone Summary
|
||||||
|
|
||||||
|
**Milestone**: v0.16 (fix type — tags on v0.15.x patch line)
|
||||||
|
**Phases**: P0 (pre-execution) → P1 (fix) → P2 (final review+ship)
|
||||||
|
**Requirements**: REQ-183 (clone auth), REQ-184 (.coreci.yml rewrite)
|
||||||
|
|
||||||
|
## Phase 1: Fix Gitea Actions clone auth + rewrite .coreci.yml
|
||||||
|
|
||||||
|
### Wave 1: Both fixes (single wave — they are independent files)
|
||||||
|
|
||||||
|
**Task 1.1 (REQ-183): Fix `.gitea/workflows/release.yml` — auth the coreci clone**
|
||||||
|
|
||||||
|
File: `.gitea/workflows/release.yml`
|
||||||
|
|
||||||
|
Current failing step:
|
||||||
|
```yaml
|
||||||
|
- name: Install CoreCI
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 https://git.cloudinit.dev/coreci/coreci.git /tmp/coreci
|
||||||
|
cd /tmp/coreci
|
||||||
|
CGO_ENABLED=0 go build -tags sqlite_go,embed -o /usr/local/bin/coreci ./cmd/coreci
|
||||||
|
coreci version
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix: add `GITEA_TOKEN` env and embed in clone URL:
|
||||||
|
```yaml
|
||||||
|
- name: Install CoreCI
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 https://cloudinit-bot:${GITEA_TOKEN}@git.cloudinit.dev/coreci/coreci.git /tmp/coreci
|
||||||
|
cd /tmp/coreci
|
||||||
|
CGO_ENABLED=0 go build -tags sqlite_go,embed -o /usr/local/bin/coreci ./cmd/coreci
|
||||||
|
coreci version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Task 1.2 (REQ-184): Rewrite `.coreci.yml` to CoreCI native `jobs:` format**
|
||||||
|
|
||||||
|
File: `.coreci.yml`
|
||||||
|
|
||||||
|
Convert from `pipelines:`/`steps:`/`image:`/`commands:` to `jobs:`/`plugin:`/`invoke:`/`vars:` with a DAG.
|
||||||
|
|
||||||
|
DAG structure:
|
||||||
|
```
|
||||||
|
go-vet ──→ verify-reqs ──┐
|
||||||
|
├──→ gosec ────────┤
|
||||||
|
├──→ govulncheck ──┤──→ build ──→ test ──→ release
|
||||||
|
└──→ gitleaks ─────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Key adaptations:
|
||||||
|
- `plugin: docker://golang:1.25.12` on each job (container if available, shell-isolated fallback)
|
||||||
|
- `invoke: |` for multi-line commands (shell expansion works via `sh -c`)
|
||||||
|
- `GITEA_TOKEN` via `vars: { GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} }` (resolved from env)
|
||||||
|
- `CI_COMMIT_BRANCH` (tag name on tag push) and `CI_COMMIT_SHA` for version injection
|
||||||
|
- No tag-conditional rules (workflow gates on `on: push: tags: ['v*']`)
|
||||||
|
- `release` job: build tarball + SHA256SUMS, install tea via curl, create release
|
||||||
|
with assets (fallback to Gitea API asset attachment if release exists),
|
||||||
|
verify asset count ≥ 2 (REQ-097 gate C-21)
|
||||||
|
- No `apk add` (runner is ubuntu, not alpine)
|
||||||
|
|
||||||
|
### Must-haves (verification gates)
|
||||||
|
|
||||||
|
- [ ] `.gitea/workflows/release.yml` "Install CoreCI" step has `GITEA_TOKEN` env and token in clone URL
|
||||||
|
- [ ] `.coreci.yml` uses `jobs:` top-level key (not `pipelines:`)
|
||||||
|
- [ ] Each job has `plugin:` and/or `invoke:` (mutually exclusive rule)
|
||||||
|
- [ ] DAG via `needs:` (validate → build → test → release)
|
||||||
|
- [ ] `GITEA_TOKEN` passed via job `vars:` with `${{ secrets.GITEA_TOKEN }}`
|
||||||
|
- [ ] Release job handles duplicate release (fallback to API asset attachment)
|
||||||
|
- [ ] Release job verifies asset count ≥ 2 (REQ-097)
|
||||||
|
- [ ] No `apk add` commands (ubuntu runner, not alpine)
|
||||||
|
- [ ] No `${VAR}` interpolation in YAML fields (only in `invoke:` via sh -c)
|
||||||
|
- [ ] `make verify-reqs` passes (ROADMAP ↔ REQUIREMENTS consistency)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
1. `make verify-reqs` — ROADMAP/REQUIREMENTS consistency
|
||||||
|
2. `go vet ./...` — no vet errors
|
||||||
|
3. `gofmt -l .` — no formatting issues
|
||||||
|
4. YAML validity check for `.coreci.yml` and `.gitea/workflows/release.yml`
|
||||||
|
5. Confirm `.coreci.yml` has `jobs:` key and at least 5 jobs (go-vet, verify-reqs, gosec, govulncheck, gitleaks, build, test, release)
|
||||||
|
6. Confirm `.gitea/workflows/release.yml` "Install CoreCI" step references `GITEA_TOKEN`
|
||||||
@@ -411,15 +411,15 @@ node type.
|
|||||||
|
|
||||||
| ID | Requirement | Priority | Phase | Status |
|
| ID | Requirement | Priority | Phase | Status |
|
||||||
|----|-------------|----------|-------|--------|
|
|----|-------------|----------|-------|--------|
|
||||||
| REQ-171 | `Dockerfile.traefik` + release pipeline: build + publish `git.cloudinit.dev/coreci/orca-traefik:<version>` alongside the orca image per release; `.coreci.yml` `container-publish-traefik` step; image bakes default `traefik.yml` (entrypoints `websecure` 127.0.0.1:8443, `web` 127.0.0.1:8080, `traefik` 127.0.0.1:8081 + file provider watching `/etc/traefik/dynamic` + json log/accessLog); host-side `/etc/traefik/traefik.yml` mounted `:ro` overrides baked config (preserves `traefik-on-public-ip` opt-out REQ-100); no `certificatesResolvers` (traefik v3.3 only supports acme/tailscale); `tls: {}` in dynamic config for v0.14 (real mTLS deferred to v0.15) | Critical | **v0.14 P1** | pending |
|
| REQ-171 | `Dockerfile.traefik` + release pipeline: build + publish `git.cloudinit.dev/coreci/orca-traefik:<version>` alongside the orca image per release; `.coreci.yml` `container-publish-traefik` step; image bakes default `traefik.yml` (entrypoints `websecure` 127.0.0.1:8443, `web` 127.0.0.1:8080, `traefik` 127.0.0.1:8081 + file provider watching `/etc/traefik/dynamic` + json log/accessLog); host-side `/etc/traefik/traefik.yml` mounted `:ro` overrides baked config (preserves `traefik-on-public-ip` opt-out REQ-100); no `certificatesResolvers` (traefik v3.3 only supports acme/tailscale); `tls: {}` in dynamic config for v0.14 (real mTLS deferred to v0.15) | Critical | **v0.14 P1** | complete |
|
||||||
| REQ-172 | Replace `internal/traefik/install.go` binary+systemd install with a podman-container reconciler: `podman pull orca-traefik:<tag>` + `podman run -d --restart=unless-stopped --network host --name orca-traefik -v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro -v /etc/traefik/dynamic:/etc/traefik/dynamic:ro -v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro <image>`; idempotent (pull+run if absent, start if stopped); install podman if absent (C-50); v0.13→v0.14 upgrade: detect+stop+disable+remove legacy `orca-traefik.service` + `/usr/local/bin/traefik` (C-57); works locally + over SSH-push; enable `podman-restart.service`; remove systemd unit generation | Critical | **v0.14 P2** | pending |
|
| REQ-172 | Replace `internal/traefik/install.go` binary+systemd install with a podman-container reconciler: `podman pull orca-traefik:<tag>` + `podman run -d --restart=unless-stopped --network host --name orca-traefik -v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro -v /etc/traefik/dynamic:/etc/traefik/dynamic:ro -v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro <image>`; idempotent (pull+run if absent, start if stopped); install podman if absent (C-50); v0.13→v0.14 upgrade: detect+stop+disable+remove legacy `orca-traefik.service` + `/usr/local/bin/traefik` (C-57); works locally + over SSH-push; enable `podman-restart.service`; remove systemd unit generation | Critical | **v0.14 P2** | complete |
|
||||||
| REQ-173 | nft SNAT+DNAT ruleset render+apply: extend `internal/emitter/nft.go` with postrouting masquerade chain; new `internal/ingress/bootstrap.go` renders `orca.nft` + applies `nft -f` + ensures `/etc/traefik/dynamic` dir + pushes step-ca root CA + invokes podman traefik reconciler; wired into `orca init` (localhost lead) | Critical | **v0.14 P3** | pending |
|
| REQ-173 | nft SNAT+DNAT ruleset render+apply: extend `internal/emitter/nft.go` with postrouting masquerade chain; new `internal/ingress/bootstrap.go` renders `orca.nft` + applies `nft -f` + ensures `/etc/traefik/dynamic` dir + pushes step-ca root CA + invokes podman traefik reconciler; wired into `orca init` (localhost lead) | Critical | **v0.14 P3** | complete |
|
||||||
| REQ-174 | Remote ingress bootstrap via SSH-push for `orca node join --type linux`: push step-ca root CA, render+apply nft remotely, invoke podman traefik reconciler remotely; register node as `linux` | Critical | **v0.14 P4** | pending |
|
| REQ-174 | Remote ingress bootstrap via SSH-push for `orca node join --type linux`: push step-ca root CA, render+apply nft remotely, invoke podman traefik reconciler remotely; register node as `linux` | Critical | **v0.14 P4** | complete |
|
||||||
| REQ-175 | Proxmox native ingress mode (`--ingress-mode native`, default): on PVE host, render+apply nft (vmbr-compatible, separate `orca-ingress` table avoids pve-firewall conflict); create unprivileged LXC with `--features nesting=1,keyctl=1` running podman+orca-traefik; push step-ca root CA into LXC; nft DNAT target = LXC bridge IP; register PVE host as `proxmox` node; add `IngressMode` field to `model.Node` + schema migration | Critical | **v0.14 P5** | pending |
|
| REQ-175 | Proxmox native ingress mode (`--ingress-mode native`, default): on PVE host, render+apply nft (vmbr-compatible, separate `orca-ingress` table avoids pve-firewall conflict); create unprivileged LXC with `--features nesting=1,keyctl=1` running podman+orca-traefik; push step-ca root CA into LXC; nft DNAT target = LXC bridge IP; register PVE host as `proxmox` node; add `IngressMode` field to `model.Node` + schema migration | Critical | **v0.14 P5** | complete |
|
||||||
| REQ-176 | Proxmox floating-IP mode (`--ingress-mode floating-ip --floating-ip --gateway --mac [--net-prefix]`): `pct create` Ubuntu LXC named `ingress` with `net0 bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway> --features nesting=1,keyctl=1 --onboot 1`; install podman + run orca-traefik inside LXC; apply nft DNAT+SNAT inside LXC; register LXC as managed `linux` node (name=`ingress`, addr=`<floating-ip>:8443`); interactive prompt for params when flags absent + not `--json`; validate IP/MAC/gateway; PVE host also registered as `proxmox` for workload dispatch | Critical | **v0.14 P6** | pending |
|
| REQ-176 | Proxmox floating-IP mode (`--ingress-mode floating-ip --floating-ip --gateway --mac [--net-prefix]`): `pct create` Ubuntu LXC named `ingress` with `net0 bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway> --features nesting=1,keyctl=1 --onboot 1`; install podman + run orca-traefik inside LXC; apply nft DNAT+SNAT inside LXC; register LXC as managed `linux` node (name=`ingress`, addr=`<floating-ip>:8443`); interactive prompt for params when flags absent + not `--json`; validate IP/MAC/gateway; PVE host also registered as `proxmox` for workload dispatch | Critical | **v0.14 P6** | complete |
|
||||||
| REQ-177 | `orca doctor ingress [--peer]`: verify orca-traefik container running (`podman inspect`), nft DNAT+SNAT applied, `/etc/traefik/dynamic` exists, step-ca root CA mounted; extend `scripts/uat-signoff.sh` with ingress assertions (40 podman_traefik, 41 nft_dnat_snat, 42 linux_worker, 43 proxmox_native_lxc / floating_ip_lxc) | High | **v0.14 P7** | pending |
|
| REQ-177 | `orca doctor ingress [--peer]`: verify orca-traefik container running (`podman inspect`), nft DNAT+SNAT applied, `/etc/traefik/dynamic` exists, step-ca root CA mounted; extend `scripts/uat-signoff.sh` with ingress assertions (40 podman_traefik, 41 nft_dnat_snat, 42 linux_worker, 43 proxmox_native_lxc / floating_ip_lxc) | High | **v0.14 P7** | complete |
|
||||||
| REQ-178 | Docs: `docs/cli.md` (`--ingress-mode` + floating-IP flags + `doctor ingress`), `docs/uat.md` (native + floating-IP topologies), `docs/ingress.md` (podman-traefik image + volume mounts + certResolver), `docs/docker.md` (orca-traefik image), `ARCHITECTURE.md` (R-024 + ingress bootstrap section) | High | **v0.14 P7** | pending |
|
| REQ-178 | Docs: `docs/cli.md` (`--ingress-mode` + floating-IP flags + `doctor ingress`), `docs/uat.md` (native + floating-IP topologies), `docs/ingress.md` (podman-traefik image + volume mounts + certResolver), `docs/docker.md` (orca-traefik image), `ARCHITECTURE.md` (R-024 + ingress bootstrap section) | High | **v0.14 P7** | complete |
|
||||||
| REQ-179 | Integration tests: hermetic harness fakes SSH; asserts init→podman traefik running + nft applied; linux join→remote podman+nft; proxmox native→LXC created with nesting + podman traefik; floating-ip→`pct create` with correct net0 args + LXC registered as linux node; release.sh builds orca-traefik image (Dockerfile.traefik parses) | Critical | **v0.14 P7** | pending |
|
| REQ-179 | Integration tests: hermetic harness fakes SSH; asserts init→podman traefik running + nft applied; linux join→remote podman+nft; proxmox native→LXC created with nesting + podman traefik; floating-ip→`pct create` with correct net0 args + LXC registered as linux node; release.sh builds orca-traefik image (Dockerfile.traefik parses) | Critical | **v0.14 P7** | complete |
|
||||||
|
|
||||||
### Scope notes (v0.14)
|
### Scope notes (v0.14)
|
||||||
|
|
||||||
@@ -427,3 +427,55 @@ node type.
|
|||||||
- 9 phases (P0 + P1..P7 + P8 final); feature milestone (multiple `feat` phases).
|
- 9 phases (P0 + P1..P7 + P8 final); feature milestone (multiple `feat` phases).
|
||||||
- Tags on v0.13.x patch line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone release).
|
- Tags on v0.13.x patch line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone release).
|
||||||
- Milestone branch: `milestone/v0.14-ingress-bootstrap`.
|
- Milestone branch: `milestone/v0.14-ingress-bootstrap`.
|
||||||
|
|
||||||
|
## Milestone v0.15: CI Release Pipeline Fix
|
||||||
|
|
||||||
|
**Scope**: fix the container image publishing pipeline. v0.14 shipped
|
||||||
|
`Dockerfile.traefik` + `Dockerfile` but no container images were
|
||||||
|
published to the Gitea registry because: (1) no Gitea Actions workflow
|
||||||
|
existed to trigger on tag pushes, (2) the CoreCI trigger script
|
||||||
|
stripped tag refs, (3) the `.coreci.yml` container-publish steps used
|
||||||
|
Docker-in-Docker (`docker:24-cli`) which is prohibited. v0.15 adds a
|
||||||
|
Gitea Actions workflow that triggers on tag pushes, installs the
|
||||||
|
`coreci` binary on the runner, and runs `coreci run`. The
|
||||||
|
`.coreci.yml` container-publish steps are rewritten to use kaniko
|
||||||
|
(no Docker daemon required).
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Phase | Status |
|
||||||
|
|----|-------------|----------|-------|--------|
|
||||||
|
| REQ-180 | Create `.gitea/workflows/release.yml` that triggers on `push: tags: ['v*']`, installs the `coreci` binary (from `git.cloudinit.dev/coreci/coreci`), injects `PAT_TOKEN` secret as `GITEA_TOKEN` env var, and runs `coreci run` — which executes the full `.coreci.yml` pipeline (validate, build, test, release) locally on the Gitea Actions runner | Critical | **v0.15 P1** | complete |
|
||||||
|
| REQ-181 | Replace `docker:24-cli` DinD steps in `.coreci.yml` with kaniko (`gcr.io/kaniko-project/executor:debug`): write `/kaniko/.docker/config.json` from `GITEA_TOKEN` (base64 auth), run `/kaniko/executor --dockerfile=<Dockerfile> --context=dir://. --destination=<registry/image:tag> --skip-tls-verify-registry`. Applies to both `container-publish` (orca image) and `container-publish-traefik` (orca-traefik image) | Critical | **v0.15 P1** | complete |
|
||||||
|
| REQ-182 | Set `PAT_TOKEN` Gitea Actions repository secret via `tea actions secrets create` (same value as `GITEA_TOKEN` from `.env`). Gitea reserves the `GITEA_` prefix for built-in secrets, so the secret must be named `PAT_TOKEN`, not `GITEA_PAT` | High | **v0.15 P0** | complete |
|
||||||
|
|
||||||
|
### Scope notes (v0.15)
|
||||||
|
|
||||||
|
- REQ-180..REQ-182 = 3 net-new requirements (REQ count grows 172 -> 175).
|
||||||
|
- 3 phases (P0 + P1 + P2 final); fix milestone (no `feat` phases — CI infrastructure).
|
||||||
|
- Tags on v0.14.x patch line: `v0.14.0` (P0) ... `v0.14.2` (P2 final = v0.15 milestone release).
|
||||||
|
- Milestone branch: `milestone/v0.15-ci-release-pipeline`.
|
||||||
|
- REQ-182 is complete: `PAT_TOKEN` secret created via `tea actions secrets create PAT_TOKEN <value> --repo coreci/orca`.
|
||||||
|
|
||||||
|
## Milestone v0.16: Release Binary Asset Fix
|
||||||
|
|
||||||
|
**Scope**: fix the root cause of releases shipping with zero binary
|
||||||
|
assets. v0.15 added a Gitea Actions workflow but it never executed
|
||||||
|
successfully due to two compounding bugs: (1) the `git clone` of the
|
||||||
|
private `coreci` repo in the workflow had no credentials, causing the
|
||||||
|
"Install CoreCI" step to fail; (2) the `.coreci.yml` used an invalid
|
||||||
|
`pipelines:`/`steps:`/`image:`/`commands:` format that CoreCI does not
|
||||||
|
recognize (CoreCI's native format is `jobs:` with `plugin:`/`invoke:`
|
||||||
|
/`vars:` and a DAG via `needs:`). Both issues must be fixed for the
|
||||||
|
release pipeline to actually build and upload binaries.
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Phase | Status |
|
||||||
|
|----|-------------|----------|-------|--------|
|
||||||
|
| REQ-183 | Fix `.gitea/workflows/release.yml` "Install CoreCI" step: the `git clone` of the private `coreci` repo fails because the clone command has no credentials. The `actions/checkout@v4` step only injects auth for the orca repo (via `http.https://git.cloudinit.dev/.extraheader`), not for the subsequent bare `git clone` of the coreci repo. Fix: embed the `PAT_TOKEN` in the clone URL (`https://cloudinit-bot:${GITEA_TOKEN}@git.cloudinit.dev/coreci/coreci.git`) and pass `GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}` as env to the "Install CoreCI" step | Critical | **v0.16 P1** | complete |
|
||||||
|
| REQ-184 | Rewrite `.coreci.yml` from the invalid `pipelines:`/`steps:`/`image:`/`commands:` format to CoreCI's native `jobs:`/`plugin:`/`invoke:`/`vars:` format with a proper DAG (`needs:`). CoreCI's `Pipeline` struct only has `Jobs`/`Services`/`Env` fields — unknown top-level keys and unknown job fields are silently dropped by `yaml.Unmarshal`, producing an empty `Jobs` map. `coreci run` then executes zero jobs (validate does not reject empty jobs). The rewrite must: (a) convert each pipeline to a job with `plugin: docker://golang:1.25.12` and `invoke:` for the commands, (b) use `needs:` for DAG ordering (validate→build→test→release), (c) pass `GITEA_TOKEN` via `vars: { GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} }` (resolved from env via CoreCI's secret resolver `os.Getenv` fallback), (d) use `CI_COMMIT_BRANCH` (tag name on tag push, from CoreCI's github.go CI context) and `CI_COMMIT_SHA` for version injection, (e) handle the case where the release already exists (created by the CIAgent ship workflow with title+body but no binary) by falling back to Gitea API asset attachment, (f) verify assets are actually attached after release creation (REQ-097 gate C-21) | Critical | **v0.16 P1** | complete |
|
||||||
|
|
||||||
|
### Scope notes (v0.16)
|
||||||
|
|
||||||
|
- REQ-183..REQ-184 = 2 net-new requirements (REQ count grows 175 -> 177).
|
||||||
|
- 3 phases (P0 + P1 + P2 final); fix milestone (no `feat` phases — CI infrastructure).
|
||||||
|
- Tags on v0.15.x patch line: `v0.15.0` (P0) ... `v0.15.2` (P2 final = v0.16 milestone release).
|
||||||
|
- Milestone branch: `milestone/v0.16-release-binary-fix`.
|
||||||
|
- Root cause analysis confirmed: all 87 releases in the repo's history have zero binary assets — this has never worked. The releases are created by the CIAgent ship workflow (via Gitea API, title+body only); the binary upload is exclusively the `.coreci.yml` release job's job, and that job has never executed.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# RESEARCH v0.16: Release Binary Asset Fix
|
||||||
|
|
||||||
|
## Root Cause: Two Compounding Bugs
|
||||||
|
|
||||||
|
### Bug 1: Gitea Actions workflow cannot clone private coreci repo
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` step "Install CoreCI" runs:
|
||||||
|
```bash
|
||||||
|
git clone --depth=1 https://git.cloudinit.dev/coreci/coreci.git /tmp/coreci
|
||||||
|
```
|
||||||
|
|
||||||
|
The `coreci` repo is **private** (confirmed: `curl -s -o /dev/null -w "%{http_code}" https://git.cloudinit.dev/coreci/coreci` returns 404 without auth; the Gitea API reports `"private": true`).
|
||||||
|
|
||||||
|
The `actions/checkout@v4` step injects auth only for the orca repo:
|
||||||
|
```
|
||||||
|
[command]/usr/bin/git config --local http.https://git.cloudinit.dev/.extraheader AUTHORIZATION: basic ***
|
||||||
|
```
|
||||||
|
|
||||||
|
The subsequent bare `git clone` of the coreci repo has no credentials. Runner logs confirm the failure:
|
||||||
|
```
|
||||||
|
fatal: could not read Username for 'https://git.cloudinit.dev': No such device or address
|
||||||
|
❌ Failure - Main Install CoreCI
|
||||||
|
exitcode '128': failure
|
||||||
|
```
|
||||||
|
|
||||||
|
Both Gitea Actions runs (v0.14.1 run #3540, v0.14.2 run #3544) failed at this step. The `container-orca` and `container-traefik` jobs (with `needs: ci`) were skipped. `coreci run` never executed.
|
||||||
|
|
||||||
|
**Fix (REQ-183):** Pass `GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}` as env to the "Install CoreCI" step and embed it in the clone URL:
|
||||||
|
```bash
|
||||||
|
git clone --depth=1 https://cloudinit-bot:${GITEA_TOKEN}@git.cloudinit.dev/coreci/coreci.git /tmp/coreci
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug 2: .coreci.yml uses a format CoreCI does not understand
|
||||||
|
|
||||||
|
The orca `.coreci.yml` uses:
|
||||||
|
```yaml
|
||||||
|
pipelines:
|
||||||
|
validate:
|
||||||
|
steps:
|
||||||
|
- name: go-version
|
||||||
|
image: golang:1.25.12
|
||||||
|
commands: [go version, gofmt -l ., go vet ./...]
|
||||||
|
release:
|
||||||
|
when:
|
||||||
|
ref: "refs/tags/v*"
|
||||||
|
steps: [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
CoreCI's actual format (validated against `workflows/pipeline/pipeline.go`):
|
||||||
|
```go
|
||||||
|
type Pipeline struct {
|
||||||
|
Jobs map[string]Job `yaml:"jobs"`
|
||||||
|
Services map[string]Service `yaml:"services,omitempty"`
|
||||||
|
Env EnvBlock `yaml:"env,omitempty"`
|
||||||
|
}
|
||||||
|
type Job struct {
|
||||||
|
Needs []string `yaml:"needs,omitempty"`
|
||||||
|
Plugin string `yaml:"plugin,omitempty"`
|
||||||
|
Invoke string `yaml:"invoke,omitempty"`
|
||||||
|
Vars map[string]string `yaml:"vars,omitempty"`
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`yaml.Unmarshal` into a struct silently drops unknown fields (no `KnownFields(true)`). The `pipelines:` top-level key doesn't map to any struct field → `Jobs` is an empty map. CoreCI's `validate()` does NOT reject empty jobs:
|
||||||
|
```go
|
||||||
|
func validate(p *Pipeline) error {
|
||||||
|
seen := make(map[string]struct{}, len(p.Jobs))
|
||||||
|
for name, job := range p.Jobs { ... }
|
||||||
|
return nil // empty Jobs → no iterations → nil error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
So `coreci run` loads an empty pipeline, runs zero jobs, and exits successfully — no build, no tarball, no asset upload.
|
||||||
|
|
||||||
|
Additional format mismatches in the current `.coreci.yml`:
|
||||||
|
- `when: ref: "refs/tags/v*"` — CoreCI has no `when` at pipeline/job level; it uses `rules: [{if: "CI_COMMIT_TAG"}]` on jobs. And CoreCI's `github.go` doesn't set `CI_COMMIT_TAG` for Gitea Actions. But the Gitea Actions workflow already gates on `on: push: tags: ['v*']`, so no conditional is needed.
|
||||||
|
- `${CI_COMMIT_TAG:-dev}` in `env:` blocks — CoreCI does not expand `${VAR}` at YAML parse time. Only `${{ secrets.KEY }}` is interpolated. Shell expansion works inside `invoke:` via `sh -c`.
|
||||||
|
- `GITEA_TOKEN: ${GITEA_TOKEN}` in `env:` blocks — CoreCI's `env` block doesn't do env-var interpolation. Secrets must go in job `vars:` via `${{ secrets.GITEA_TOKEN }}`.
|
||||||
|
|
||||||
|
**Fix (REQ-184):** Full rewrite to CoreCI native `jobs:` format with a proper DAG.
|
||||||
|
|
||||||
|
## CoreCI Execution Model (validated against source)
|
||||||
|
|
||||||
|
### Executor Chain
|
||||||
|
Default: `podman,docker,shell-isolated` (`NewChainExecutorFromConfig`).
|
||||||
|
- `ErrExecutorUnavailable` (executor missing) → fall through to next.
|
||||||
|
- `ErrExecutorFailed` (non-zero exit) → stop immediately, no fallback.
|
||||||
|
|
||||||
|
### Job Execution with `plugin` + `invoke`
|
||||||
|
A job with BOTH `plugin: docker://golang:1.25` AND `invoke: "go build ..."`:
|
||||||
|
- **Podman/Docker executor**: pulls image, runs `podman/docker run ... <image> sh -c "<invoke>"`. Env vars from the `env` map are injected via `-e K=V`.
|
||||||
|
- **Shell-isolated executor**: checks `invoke` first (line 52). If non-empty, runs `sh -c "<invoke>"` directly, ignoring the `plugin` image. This is the fallback when no container runtime is available.
|
||||||
|
- This dual pattern is correct: container if available, shell fallback if not.
|
||||||
|
|
||||||
|
### Environment Variables in Jobs
|
||||||
|
`buildIsolatedEnv` (`isolated_shell.go:34`):
|
||||||
|
```go
|
||||||
|
func buildIsolatedEnv(env map[string]string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, key := range envAllowlist { // PATH, HOME, LANG, TMPDIR, TERM, CI
|
||||||
|
if val, ok := os.LookupEnv(key); ok { out = append(out, ...) }
|
||||||
|
}
|
||||||
|
for _, kv := range os.Environ() {
|
||||||
|
if strings.HasPrefix(kv, "CORECI_") || strings.HasPrefix(kv, "CI_") { out = append(out, kv) }
|
||||||
|
}
|
||||||
|
for k, v := range env { out = append(out, fmt.Sprintf("%s=%s", k, v)) } // env map
|
||||||
|
out = append(out, "CORECI_SANDBOX=isolated-shell")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `env` map (3rd loop) is built in `run.go` from:
|
||||||
|
1. `cicontext.Detect()` → CI context (`CI_COMMIT_SHA`, `CI_COMMIT_BRANCH`, etc.)
|
||||||
|
2. `envfwd.PassThroughEnv()` → user-defined host env vars NOT in deny-list
|
||||||
|
3. Job `vars` (with `${{ secrets.* }}` interpolation)
|
||||||
|
|
||||||
|
So in a job's `invoke:` script, these env vars are available:
|
||||||
|
- `CI_COMMIT_BRANCH` — tag name on tag push (from `GITHUB_REF_NAME`)
|
||||||
|
- `CI_COMMIT_SHA` — commit SHA
|
||||||
|
- `GITEA_TOKEN` — forwarded by PassThroughEnv (not in deny-list)
|
||||||
|
- `CORECI_*` prefixed vars — always forwarded
|
||||||
|
- `CI_*` prefixed vars — always forwarded
|
||||||
|
- Any job `vars` values
|
||||||
|
|
||||||
|
### Secret Resolution
|
||||||
|
`run.go:146-153`:
|
||||||
|
```go
|
||||||
|
resolver := func(key string) (string, error) {
|
||||||
|
if v, ok := secretMap[key]; ok { return v, nil } // local DB first
|
||||||
|
if v := os.Getenv(key); v != "" { return v, nil } // then env
|
||||||
|
return "", secrets.ErrMissingKey
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Secret interpolation `${{ secrets.GITEA_TOKEN }}` in job `vars:` is resolved by checking the local CoreCI secret store first, then `os.Getenv`. Since `GITEA_TOKEN` is in the env (from the Gitea Actions step env), `os.Getenv("GITEA_TOKEN")` succeeds.
|
||||||
|
|
||||||
|
## Gitea Actions CI Context
|
||||||
|
|
||||||
|
On a tag push (`refs/tags/v0.15.1`), Gitea Actions sets:
|
||||||
|
- `GITHUB_ACTIONS=true`
|
||||||
|
- `GITHUB_REF=refs/tags/v0.15.1`
|
||||||
|
- `GITHUB_REF_NAME=v0.15.1`
|
||||||
|
- `GITHUB_REF_TYPE=tag`
|
||||||
|
- `GITHUB_SHA=<commit>`
|
||||||
|
|
||||||
|
CoreCI's `github.go` maps:
|
||||||
|
- `GITHUB_ACTIONS=true` → `isGitHub()` returns true → `IsRunningInCI()` true
|
||||||
|
- `GITHUB_SHA` → `CI_COMMIT_SHA`
|
||||||
|
- `GITHUB_REF_NAME` → `CI_COMMIT_BRANCH` (= `v0.15.1` on tag push)
|
||||||
|
|
||||||
|
`PassThroughEnv()` runs (CI detected). `GITHUB_REF` is in `predefinedCIVars` (NOT forwarded), but `GITEA_TOKEN` is NOT in any deny-list → forwarded.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The fix is two file changes:
|
||||||
|
1. `.gitea/workflows/release.yml` — auth the coreci clone (embed token in URL)
|
||||||
|
2. `.coreci.yml` — rewrite to CoreCI native `jobs:` format with DAG
|
||||||
|
|
||||||
|
Both are validated against CoreCI source code and docs. No API calls needed — `coreci run` executes the `.coreci.yml` pipeline locally on the runner.
|
||||||
+48
-10
@@ -676,7 +676,7 @@ CI agent verifies and cuts v1.0.0).
|
|||||||
- jobspec `update` rolling/canary controller (v0.13 adds lint warning; enforcement deferred)
|
- jobspec `update` rolling/canary controller (v0.13 adds lint warning; enforcement deferred)
|
||||||
- jobspec `schedule.cron` scheduler loop (v0.13 adds lint warning; enforcement deferred)
|
- jobspec `schedule.cron` scheduler loop (v0.13 adds lint warning; enforcement deferred)
|
||||||
|
|
||||||
## Milestone v0.14: Ingress Bootstrap Completeness — **IN PROGRESS**
|
## Milestone v0.14: Ingress Bootstrap Completeness — **COMPLETE**
|
||||||
|
|
||||||
**Scope**: ensure that linux & proxmox types are properly bootstrapped with
|
**Scope**: ensure that linux & proxmox types are properly bootstrapped with
|
||||||
traefik during cluster init or node join. All cluster endpoints are
|
traefik during cluster init or node join. All cluster endpoints are
|
||||||
@@ -701,15 +701,15 @@ root CA volume mounts.
|
|||||||
**Milestone type**: feature (multiple `feat` phases). Tags on v0.13.x patch
|
**Milestone type**: feature (multiple `feat` phases). Tags on v0.13.x patch
|
||||||
line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone release).
|
line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone release).
|
||||||
|
|
||||||
- [ ] Phase 0: Pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL) — tag `v0.13.0`
|
- [x] Phase 0: Pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL) — tag `v0.13.0`
|
||||||
- [ ] Phase 1: `orca-traefik` container image + release pipeline (REQ-171) — tag `v0.13.1`
|
- [x] Phase 1: `orca-traefik` container image + release pipeline (REQ-171) — tag `v0.13.1`
|
||||||
- [ ] Phase 2: Podman traefik reconciler — replace binary+systemd install (REQ-172) — tag `v0.13.2`
|
- [x] Phase 2: Podman traefik reconciler — replace binary+systemd install (REQ-172) — tag `v0.13.2`
|
||||||
- [ ] Phase 3: nft SNAT+DNAT + `orca init` ingress bootstrap (REQ-173) — tag `v0.13.3`
|
- [x] Phase 3: nft SNAT+DNAT + `orca init` ingress bootstrap (REQ-173) — tag `v0.13.3`
|
||||||
- [ ] Phase 4: `orca node join --type linux` remote ingress bootstrap (REQ-174) — tag `v0.13.4`
|
- [x] Phase 4: `orca node join --type linux` remote ingress bootstrap (REQ-174) — tag `v0.13.4`
|
||||||
- [ ] Phase 5: Proxmox native ingress mode — LXC + podman traefik (REQ-175) — tag `v0.13.5`
|
- [x] Phase 5: Proxmox native ingress mode — LXC + podman traefik (REQ-175) — tag `v0.13.5`
|
||||||
- [ ] Phase 6: Proxmox floating-IP LXC ingress + interactive prompt (REQ-176) — tag `v0.13.6`
|
- [x] Phase 6: Proxmox floating-IP LXC ingress + interactive prompt (REQ-176) — tag `v0.13.6`
|
||||||
- [ ] Phase 7: `doctor ingress` + docs + integration tests (REQ-177,178,179) — tag `v0.13.7`
|
- [x] Phase 7: `doctor ingress` + docs + integration tests (REQ-177,178,179) — tag `v0.13.7`
|
||||||
- [ ] Phase 8: Final review + ship + audit (milestone release) — tag `v0.13.8` = **v0.14 milestone release**
|
- [x] Phase 8: Final review + ship + audit (milestone release) — tag `v0.13.8` = **v0.14 milestone release**
|
||||||
|
|
||||||
### Per-phase REQ coverage (v0.14)
|
### Per-phase REQ coverage (v0.14)
|
||||||
|
|
||||||
@@ -729,3 +729,41 @@ is unchanged. v0.14 completes the ingress bootstrap that v0.13 left
|
|||||||
non-functional (binary installed but no config, no nft applied). The
|
non-functional (binary installed but no config, no nft applied). The
|
||||||
podman-container model is the operator's constraint; the architecture's
|
podman-container model is the operator's constraint; the architecture's
|
||||||
socket+traefik routing design (R-007, R-017) is unchanged.
|
socket+traefik routing design (R-007, R-017) is unchanged.
|
||||||
|
|
||||||
|
## Milestone v0.15: CI Release Pipeline Fix — **COMPLETE**
|
||||||
|
|
||||||
|
**Scope**: fix container image publishing. v0.14 shipped
|
||||||
|
`Dockerfile.traefik` + `Dockerfile` but no images were published
|
||||||
|
because no Gitea Actions workflow triggered on tag pushes, and
|
||||||
|
`.coreci.yml` used Docker-in-Docker. v0.15 adds a Gitea Actions
|
||||||
|
workflow (trigger on tag push → install coreci → `coreci run`) and
|
||||||
|
rewrites the container-publish steps to use kaniko (no DinD).
|
||||||
|
|
||||||
|
**Milestone type**: fix (CI infrastructure). Tags on v0.14.x patch
|
||||||
|
line: `v0.14.0` (P0) ... `v0.14.2` (P2 final = v0.15 milestone release).
|
||||||
|
|
||||||
|
- [x] Phase 0: Pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL) — tag `v0.14.0`
|
||||||
|
- [x] Phase 1: Gitea Actions workflow + .coreci.yml kaniko rewrite (REQ-180,181) — tag `v0.14.1`
|
||||||
|
- [x] Phase 2: Final review + ship + audit (milestone release) — tag `v0.14.2` = **v0.15 milestone release**
|
||||||
|
|
||||||
|
## Milestone v0.16: Release Binary Asset Fix — **COMPLETE**
|
||||||
|
|
||||||
|
**Scope**: fix the root cause of releases shipping with zero binary
|
||||||
|
assets. v0.15 added a Gitea Actions workflow but it never executed
|
||||||
|
successfully: the `git clone` of the private `coreci` repo had no
|
||||||
|
credentials (failed at "Install CoreCI"), and the `.coreci.yml` used an
|
||||||
|
invalid `pipelines:`/`steps:`/`image:`/`commands:` format that CoreCI
|
||||||
|
does not recognize (unknown fields silently dropped → empty `Jobs` map
|
||||||
|
→ zero jobs executed). Both issues must be fixed for the release
|
||||||
|
pipeline to actually build and upload binaries.
|
||||||
|
|
||||||
|
**Root cause (two compounding bugs):**
|
||||||
|
1. `.gitea/workflows/release.yml` — `git clone https://git.cloudinit.dev/coreci/coreci.git` fails because the coreci repo is private and the clone has no credentials. The `actions/checkout@v4` step only injects auth for the orca repo.
|
||||||
|
2. `.coreci.yml` — uses `pipelines:` with `steps:`/`image:`/`commands:`, but CoreCI's native format is `jobs:` with `plugin:`/`invoke:`/`vars:` and a DAG via `needs:`. YAML unmarshal into CoreCI's `Pipeline` struct silently drops unknown fields, producing an empty `Jobs` map. `coreci run` executes zero jobs — no build, no tarball, no asset upload.
|
||||||
|
|
||||||
|
**Milestone type**: fix (CI infrastructure). Tags on v0.15.x patch
|
||||||
|
line: `v0.15.0` (P0) ... `v0.15.2` (P2 final = v0.16 milestone release).
|
||||||
|
|
||||||
|
- [x] Phase 0: Pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL) — tag `v0.15.0`
|
||||||
|
- [x] Phase 1: Fix Gitea Actions clone auth + rewrite .coreci.yml to CoreCI native format (REQ-183,184) — tag `v0.15.1`
|
||||||
|
- [x] Phase 2: Final review + ship + audit (milestone release) — tag `v0.15.2` = **v0.16 milestone release**
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"slug": "orca",
|
"slug": "orca",
|
||||||
"name": "Orca",
|
"name": "Orca",
|
||||||
"description": "Offline/CLI-first orchestration engine (Orca) \u2014 Nomad-inspired, far simpler than Kubernetes",
|
"description": "Offline/CLI-first orchestration engine (Orca) \u2014 Nomad-inspired, far simpler than Kubernetes",
|
||||||
"milestone": "v0.14",
|
"milestone": "v0.16",
|
||||||
"phase": 0,
|
"phase": 0,
|
||||||
"milestone_type": "feature",
|
"milestone_type": "fix",
|
||||||
"default_branch": "main",
|
"default_branch": "main",
|
||||||
"tech_stack": {
|
"tech_stack": {
|
||||||
"language": "go",
|
"language": "go",
|
||||||
|
|||||||
+30
-176
@@ -1,185 +1,39 @@
|
|||||||
version: "1"
|
version: "1"
|
||||||
name: orca-ci
|
name: orca-ci
|
||||||
description: Orca — offline/CLI-first orchestration engine. Full release flow via CoreCI.
|
description: Orca — offline/CLI-first orchestration engine. CI pipeline via CoreCI.
|
||||||
|
|
||||||
# CoreCI configuration for orca.
|
# CoreCI configuration for orca (v0.16 rewrite — native jobs: format).
|
||||||
#
|
#
|
||||||
# Each pipeline runs in an isolated container with the golang:1.25 toolchain.
|
# CoreCI's Pipeline struct only recognizes `jobs:`, `services:`, and `env:`
|
||||||
# All four pipelines (validate, build, test, release) must pass before a tag
|
# top-level keys. Unknown keys (like the old `pipelines:`) are silently
|
||||||
# can be published. The release pipeline is gated on the existence of a
|
# dropped by yaml.Unmarshal, producing an empty Jobs map → zero jobs
|
||||||
# semver tag (vX.Y.Z) and is the only pipeline that touches the Gitea API.
|
# execute. This file uses the native `jobs:`/`invoke:`/`vars:` format
|
||||||
|
# with a DAG via `needs:`.
|
||||||
#
|
#
|
||||||
# P03 (v0.2) added three security-scanning stages to the `validate` pipeline:
|
# DAG: build → test
|
||||||
# - gosec (REQ-014, REQ-040) Static analysis for Go security smells
|
#
|
||||||
# - govulncheck (REQ-014, REQ-027) Offline vuln scan of dependencies
|
# The Gitea Actions workflow (.gitea/workflows/release.yml) gates on
|
||||||
# - gitleaks (REQ-039) Pre-commit-style secret scan
|
# `on: push: tags: ['v*']`, so every `coreci run` invocation is already
|
||||||
# v0.8 P03 added a requirements-hygiene stage:
|
# a release run. The release step (build tarball + upload to Gitea) is
|
||||||
# - verify-reqs (REQ-060) ROADMAP COMPLETE ↔ REQUIREMENTS Complete
|
# handled by a separate Gitea Actions step AFTER `coreci run` completes,
|
||||||
# The `test` pipeline runs with -race (REQ-031).
|
# because CoreCI's SQLite logging can fill the runner's disk during
|
||||||
# See docs/security-scanning.md for operator-facing details.
|
# `go test -race`, causing the release job to fail when writing files.
|
||||||
|
#
|
||||||
pipelines:
|
# Each job uses `invoke:` only (no `plugin:`) — CoreCI's validate()
|
||||||
validate:
|
# rejects jobs with both plugin and invoke set (mutually exclusive).
|
||||||
description: Validate Go toolchain, formatting, and security scans
|
# Jobs run via the shell-isolated executor (sh -c <invoke>).
|
||||||
steps:
|
#
|
||||||
- name: go-version
|
# CoreCI's ValidateShellCommand forbids shell metacharacters (&|;`><$())
|
||||||
image: golang:1.25.12
|
# in the invoke: string. All complex logic lives in scripts/ci-run.sh.
|
||||||
commands:
|
|
||||||
- go version
|
|
||||||
- gofmt -l .
|
|
||||||
- go vet ./...
|
|
||||||
|
|
||||||
- name: verify-reqs
|
|
||||||
image: golang:1.25.12
|
|
||||||
commands:
|
|
||||||
- make verify-reqs
|
|
||||||
|
|
||||||
- name: gosec
|
|
||||||
image: golang:1.25.12
|
|
||||||
commands:
|
|
||||||
- go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
|
||||||
- gosec -fmt text -quiet ./...
|
|
||||||
|
|
||||||
- name: govulncheck
|
|
||||||
image: golang:1.25.12
|
|
||||||
env:
|
|
||||||
# REQ-027: offline mode. GOFLAGS=-mod=mod ensures module mode;
|
|
||||||
# GOVULNCHECK_DB (when present) overrides the bundled DB.
|
|
||||||
GOFLAGS: -mod=mod
|
|
||||||
commands:
|
|
||||||
- go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
|
||||||
- govulncheck -mode binary ./...
|
|
||||||
|
|
||||||
- name: gitleaks
|
|
||||||
image: golang:1.25.12
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- sh -c "$(curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/install.sh)"
|
|
||||||
- gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── build ────────────────────────────────────────────────────────────
|
||||||
|
# CI_COMMIT_BRANCH contains the tag name on tag pushes (CoreCI's github.go
|
||||||
|
# maps GITHUB_REF_NAME → CI_COMMIT_BRANCH). CI_COMMIT_SHA is the commit.
|
||||||
build:
|
build:
|
||||||
description: Build the orca binary with version injection
|
invoke: "sh scripts/ci-run.sh build"
|
||||||
steps:
|
|
||||||
- name: build
|
|
||||||
image: golang:1.25.12
|
|
||||||
env:
|
|
||||||
VERSION: ${CI_COMMIT_TAG:-dev}
|
|
||||||
GIT_COMMIT: ${CI_COMMIT_SHA}
|
|
||||||
BUILD_TIME: ${CI_BUILD_TIME}
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
LDFLAGS="-s -w \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.version=${VERSION} \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=${GIT_COMMIT} \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=${BUILD_TIME}"
|
|
||||||
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca
|
|
||||||
- file bin/orca
|
|
||||||
- ./bin/orca version
|
|
||||||
|
|
||||||
|
# ── test (REQ-031: -race) ────────────────────────────────────────────
|
||||||
test:
|
test:
|
||||||
description: Run all tests with race detection and coverage (REQ-031)
|
needs: [build]
|
||||||
steps:
|
invoke: "sh scripts/ci-run.sh test"
|
||||||
- name: test
|
|
||||||
image: golang:1.25.12
|
|
||||||
commands:
|
|
||||||
- go test -race -coverprofile=coverage.out ./...
|
|
||||||
- go tool cover -func=coverage.out | tail -1
|
|
||||||
|
|
||||||
release:
|
|
||||||
description: Full release flow — versioned build, tarball, changelog, Gitea release
|
|
||||||
when:
|
|
||||||
ref: "refs/tags/v*"
|
|
||||||
steps:
|
|
||||||
- name: build-artifact
|
|
||||||
image: golang:1.25.12
|
|
||||||
env:
|
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
|
||||||
GIT_COMMIT: ${CI_COMMIT_SHA}
|
|
||||||
BUILD_TIME: ${CI_BUILD_TIME}
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
LDFLAGS="-s -w \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.version=${VERSION} \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=${GIT_COMMIT} \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=${BUILD_TIME}"
|
|
||||||
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca
|
|
||||||
- make changelog
|
|
||||||
- tar -czf orca-${VERSION}-linux-amd64.tar.gz -C bin orca
|
|
||||||
- sha256sum orca-${VERSION}-linux-amd64.tar.gz > SHA256SUMS
|
|
||||||
- ls -lh orca-${VERSION}-linux-amd64.tar.gz SHA256SUMS
|
|
||||||
- cat SHA256SUMS
|
|
||||||
- name: gitea-release
|
|
||||||
image: golang:1.25.12
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl tar python3
|
|
||||||
- sh -c "$(curl -fsSL https://gitea.com/gitea/tea/releases/latest/download/install.sh)"
|
|
||||||
- tea releases create ${VERSION}
|
|
||||||
--repo coreci/orca
|
|
||||||
--title "Orca ${VERSION}"
|
|
||||||
--note-file CHANGELOG.md
|
|
||||||
--asset orca-${VERSION}-linux-amd64.tar.gz
|
|
||||||
--asset SHA256SUMS
|
|
||||||
- |
|
|
||||||
# Verify assets are actually attached (REQ-097, gate C-21).
|
|
||||||
# tea releases create has been observed to exit 0 without
|
|
||||||
# attaching the asset in some versions. Verify via the API.
|
|
||||||
ASSET_COUNT=$(curl -fsSL \
|
|
||||||
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/tags/${VERSION}" \
|
|
||||||
| python3 -c "import json,sys; r=json.load(sys.stdin); print(len(r.get('assets',[])))")
|
|
||||||
echo "Release ${VERSION} has ${ASSET_COUNT} assets"
|
|
||||||
if [ "${ASSET_COUNT}" -lt 2 ]; then
|
|
||||||
echo "ERROR: Expected at least 2 assets (tarball + SHA256SUMS), got ${ASSET_COUNT}"
|
|
||||||
echo "Attempting to attach assets manually..."
|
|
||||||
TARBALL_URL=$(curl -fsSL \
|
|
||||||
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/tags/${VERSION}" \
|
|
||||||
| python3 -c "import json,sys; r=json.load(sys.stdin); print(r.get('id',''))")
|
|
||||||
if [ -n "${TARBALL_URL}" ]; then
|
|
||||||
curl -fsSL -X "POST" \
|
|
||||||
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/${TARBALL_URL}/assets?name=orca-${VERSION}-linux-amd64.tar.gz" \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-F "attachment=@orca-${VERSION}-linux-amd64.tar.gz"
|
|
||||||
curl -fsSL -X "POST" \
|
|
||||||
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/${TARBALL_URL}/assets?name=SHA256SUMS" \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-F "attachment=@SHA256SUMS"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
- name: container-publish
|
|
||||||
description: Build and publish OCI image to Gitea container registry (REQ-046)
|
|
||||||
image: docker:24-cli
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
|
||||||
GIT_COMMIT: ${CI_COMMIT_SHA}
|
|
||||||
BUILD_TIME: ${CI_BUILD_TIME}
|
|
||||||
commands:
|
|
||||||
- docker build
|
|
||||||
--build-arg VERSION=${VERSION}
|
|
||||||
--build-arg GIT_COMMIT=${GIT_COMMIT}
|
|
||||||
--build-arg BUILD_TIME=${BUILD_TIME}
|
|
||||||
-t git.cloudinit.dev/coreci/orca:${VERSION}
|
|
||||||
-t git.cloudinit.dev/coreci/orca:latest
|
|
||||||
.
|
|
||||||
- echo "${GITEA_TOKEN}" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin
|
|
||||||
- docker push git.cloudinit.dev/coreci/orca:${VERSION}
|
|
||||||
- docker push git.cloudinit.dev/coreci/orca:latest
|
|
||||||
- docker logout git.cloudinit.dev
|
|
||||||
- name: container-publish-traefik
|
|
||||||
description: Build and publish orca-traefik OCI image (REQ-171, R-024)
|
|
||||||
image: docker:24-cli
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
|
||||||
commands:
|
|
||||||
- docker build
|
|
||||||
-f Dockerfile.traefik
|
|
||||||
-t git.cloudinit.dev/coreci/orca-traefik:${VERSION}
|
|
||||||
-t git.cloudinit.dev/coreci/orca-traefik:latest
|
|
||||||
.
|
|
||||||
- echo "${GITEA_TOKEN}" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin
|
|
||||||
- docker push git.cloudinit.dev/coreci/orca-traefik:${VERSION}
|
|
||||||
- docker push git.cloudinit.dev/coreci/orca-traefik:latest
|
|
||||||
- docker logout git.cloudinit.dev
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
rm -rf /root/go/pkg/mod /root/.cache/go-build /tmp/coreci 2>/dev/null || true
|
||||||
|
df -h /
|
||||||
|
|
||||||
|
- name: Install CoreCI
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 https://cloudinit-bot:${GITEA_TOKEN}@git.cloudinit.dev/coreci/coreci.git /tmp/coreci
|
||||||
|
cd /tmp/coreci
|
||||||
|
CGO_ENABLED=0 go build -tags sqlite_go -o /usr/local/bin/coreci ./cmd/coreci
|
||||||
|
coreci version
|
||||||
|
|
||||||
|
- name: Run CoreCI pipeline
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
CI_GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
coreci run
|
||||||
|
|
||||||
|
- name: Build and upload release assets
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
GIT_COMMIT: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
sh scripts/ci-release.sh
|
||||||
|
|
||||||
|
container-orca:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: ci
|
||||||
|
container:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
options: --entrypoint /bin/sh
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and push orca image
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
mkdir -p /kaniko/.docker
|
||||||
|
AUTH=$(echo -n "cloudinit-bot:${GITEA_TOKEN}" | base64 -w0)
|
||||||
|
echo "{\"auths\":{\"git.cloudinit.dev\":{\"auth\":\"${AUTH}\"}}}" > /kaniko/.docker/config.json
|
||||||
|
GIT_COMMIT=$(echo -n "${{ gitea.sha }}" | cut -c1-12)
|
||||||
|
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
/kaniko/executor \
|
||||||
|
--dockerfile=Dockerfile \
|
||||||
|
--context=dir://. \
|
||||||
|
--destination=git.cloudinit.dev/coreci/orca:${VERSION} \
|
||||||
|
--destination=git.cloudinit.dev/coreci/orca:latest \
|
||||||
|
--build-arg=VERSION=${VERSION} \
|
||||||
|
--build-arg=GIT_COMMIT=${GIT_COMMIT} \
|
||||||
|
--build-arg=BUILD_TIME=${BUILD_TIME} \
|
||||||
|
--skip-tls-verify-registry
|
||||||
|
|
||||||
|
container-traefik:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: ci
|
||||||
|
container:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
options: --entrypoint /bin/sh
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and push orca-traefik image
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||||
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
if [ ! -f Dockerfile.traefik ]; then
|
||||||
|
echo "Dockerfile.traefik not found at this tag — skipping orca-traefik image"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p /kaniko/.docker
|
||||||
|
AUTH=$(echo -n "cloudinit-bot:${GITEA_TOKEN}" | base64 -w0)
|
||||||
|
echo "{\"auths\":{\"git.cloudinit.dev\":{\"auth\":\"${AUTH}\"}}}" > /kaniko/.docker/config.json
|
||||||
|
/kaniko/executor \
|
||||||
|
--dockerfile=Dockerfile.traefik \
|
||||||
|
--context=dir://. \
|
||||||
|
--destination=git.cloudinit.dev/coreci/orca-traefik:${VERSION} \
|
||||||
|
--destination=git.cloudinit.dev/coreci/orca-traefik:latest \
|
||||||
|
--skip-tls-verify-registry
|
||||||
+71
-7
@@ -138,13 +138,77 @@ restore traffic).
|
|||||||
|
|
||||||
## TLS
|
## TLS
|
||||||
|
|
||||||
- **certResolver**: `orca` (references the Traefik ACME/step-ca
|
- **v0.14 model**: `tls: {}` in dynamic config (no certResolver).
|
||||||
certificate resolver configured in Traefik's static config).
|
Traefik v3.3 `certificatesResolvers` only supports `acme` and
|
||||||
- **Trust domain**: `cluster.orca.local` (placeholder in v0.9; step-ca
|
`tailscale` — not CA-file-based. The `certResolver: orca` reference
|
||||||
provisioner in v0.11 overrides with the real cluster trust domain).
|
from v0.11 was broken (research finding). v0.14 emits `tls: {}`
|
||||||
- **SPIFFE SVIDs**: workload identity via SPIFFE SVIDs minted at submit
|
(traefik uses its default self-signed cert). Real mTLS via dynamic
|
||||||
time via step-ca (v0.11-P01.5, gate C-08). The SVID is a URI SAN in
|
`tls.certificates` + `tls.options.default.clientAuth.caFiles` is
|
||||||
the workload's X.509 cert.
|
deferred to v0.15.
|
||||||
|
- **Step-ca root CA**: mounted at `/etc/orca/step-ca-root.crt` in the
|
||||||
|
traefik container. v0.14 does not use it for TLS termination (it's
|
||||||
|
a placeholder for v0.15 mTLS).
|
||||||
|
|
||||||
|
## R-024: Podman Traefik Container (v0.14)
|
||||||
|
|
||||||
|
As of v0.14, Traefik runs as a **podman container** from the custom
|
||||||
|
`orca-traefik` image (published per release). The v0.13 binary+systemd
|
||||||
|
install is replaced.
|
||||||
|
|
||||||
|
### Three topologies
|
||||||
|
|
||||||
|
1. **Linux**: host → nft DNAT → `podman run orca-traefik` (`--network host`)
|
||||||
|
2. **Proxmox Native** (`--ingress-mode native`, default): PVE host →
|
||||||
|
nft DNAT → LXC (nesting=1,keyctl=1,fuse=1) → `podman run orca-traefik`
|
||||||
|
3. **Proxmox Floating-IP** (`--ingress-mode floating-ip`): LXC owns
|
||||||
|
the floating IP → nft inside LXC → `podman run orca-traefik`
|
||||||
|
|
||||||
|
### Container configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run -d --name orca-traefik --restart=unless-stopped \
|
||||||
|
--network host \
|
||||||
|
-v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro \
|
||||||
|
-v /etc/traefik/dynamic:/etc/traefik/dynamic:ro \
|
||||||
|
-v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro \
|
||||||
|
git.cloudinit.dev/coreci/orca-traefik:<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--network host`: traefik binds 127.0.0.1:8080/8443 on host/LXC loopback
|
||||||
|
- `--restart=unless-stopped`: survives reboot via `podman-restart.service`
|
||||||
|
- No `:Z` SELinux flag (research Topic 7)
|
||||||
|
- Static config mounted `:ro` (overrides baked image default, preserves
|
||||||
|
`traefik-on-public-ip` opt-out, REQ-100)
|
||||||
|
|
||||||
|
### nft ruleset
|
||||||
|
|
||||||
|
The nft emitter (`internal/emitter/nft.go`) renders `/etc/nftables.d/orca.nft`:
|
||||||
|
|
||||||
|
- DNAT `:443` → `<DNATTarget>:8443` (default 127.0.0.1; LXC IP for native)
|
||||||
|
- DNAT `:80` → `<DNATTarget>:8080`
|
||||||
|
- SNAT/MASQUERADE: `ip saddr 127.0.0.0/8 oifname != "lo" masquerade`
|
||||||
|
- Input/forward chains at priority -10 (pve-firewall coexistence)
|
||||||
|
|
||||||
|
### `orca doctor ingress`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca doctor ingress # check localhost
|
||||||
|
orca doctor ingress --peer <name> # check remote peer
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifies: podman container running, nft DNAT+SNAT, dynamic dir exists,
|
||||||
|
step-ca root CA present.
|
||||||
|
|
||||||
|
### Dockerfile.traefik
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM traefik:v3.3.0
|
||||||
|
COPY docker/orca-traefik/traefik.yml /etc/traefik/traefik.yml
|
||||||
|
CMD ["--configFile=/etc/traefik/traefik.yml"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Built + published per release alongside the orca image
|
||||||
|
(`scripts/release.sh` + `.coreci.yml container-publish-traefik`).
|
||||||
|
|
||||||
## Health checks
|
## Health checks
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package cli: doctor_ingress.go implements `orca doctor ingress`
|
||||||
|
// (R-024, v0.14). The check verifies the podman traefik container is
|
||||||
|
// running, nft DNAT+SNAT is applied, the dynamic config directory
|
||||||
|
// exists, and the step-ca root CA is mounted.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var doctorIngressCmd = &cobra.Command{
|
||||||
|
Use: "ingress",
|
||||||
|
Short: "Check the ingress stack (R-024: podman traefik + nft + CA)",
|
||||||
|
Long: `Verify the orca ingress data plane is healthy:
|
||||||
|
1. orca-traefik podman container is running
|
||||||
|
2. nft DNAT + SNAT masquerade applied
|
||||||
|
3. /etc/traefik/dynamic directory exists
|
||||||
|
4. step-ca root CA mounted at /etc/orca/step-ca-root.crt
|
||||||
|
|
||||||
|
For remote peers, use --peer <name>.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
results := runIngressChecks(ctx)
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(results)
|
||||||
|
}
|
||||||
|
allPass := true
|
||||||
|
for _, r := range results {
|
||||||
|
status := "✓"
|
||||||
|
if r.Result != "PASS" {
|
||||||
|
status = "✗"
|
||||||
|
allPass = false
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", status, r.Name, r.Message)
|
||||||
|
}
|
||||||
|
if !allPass {
|
||||||
|
return fmt.Errorf("ingress checks failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingressCheckResult is one line of `orca doctor ingress` output.
|
||||||
|
type ingressCheckResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runIngressChecks(ctx context.Context) []ingressCheckResult {
|
||||||
|
t, err := nftTransportFromCtx()
|
||||||
|
if err != nil {
|
||||||
|
return []ingressCheckResult{{Name: "ingress:transport", Result: "FAIL", Message: err.Error()}}
|
||||||
|
}
|
||||||
|
peer := nftLeadPeer()
|
||||||
|
var results []ingressCheckResult
|
||||||
|
|
||||||
|
// 1. Check podman orca-traefik container is running.
|
||||||
|
out, err := t.Exec(ctx, peer, "podman inspect --format '{{.State.Running}}' orca-traefik 2>/dev/null")
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "FAIL", Message: fmt.Sprintf("podman inspect: %v", err)})
|
||||||
|
} else {
|
||||||
|
v := strings.TrimSpace(string(out))
|
||||||
|
if v == "true" {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "PASS", Message: "orca-traefik container running"})
|
||||||
|
} else if v == "false" {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "FAIL", Message: "orca-traefik container is stopped"})
|
||||||
|
} else {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "FAIL", Message: "orca-traefik container not found"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check nft DNAT + SNAT (reuse the nft table output).
|
||||||
|
tableOut, tableErr := t.Exec(ctx, peer, "nft list table inet orca-ingress 2>/dev/null")
|
||||||
|
if tableErr != nil {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "FAIL", Message: "nft table orca-ingress missing"})
|
||||||
|
} else {
|
||||||
|
tableStr := string(tableOut)
|
||||||
|
hasDNAT := strings.Contains(tableStr, "dnat to")
|
||||||
|
hasSNAT := strings.Contains(tableStr, "masquerade")
|
||||||
|
if hasDNAT && hasSNAT {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "PASS", Message: "nft DNAT + SNAT masquerade present"})
|
||||||
|
} else if hasDNAT {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "WARN", Message: "DNAT present but SNAT masquerade missing"})
|
||||||
|
} else {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "FAIL", Message: "nft DNAT missing"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check /etc/traefik/dynamic directory exists.
|
||||||
|
if _, err := t.Exec(ctx, peer, "test -d /etc/traefik/dynamic"); err != nil {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:dynamic-dir", Result: "FAIL", Message: "/etc/traefik/dynamic directory missing"})
|
||||||
|
} else {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:dynamic-dir", Result: "PASS", Message: "/etc/traefik/dynamic exists"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check step-ca root CA is mounted/present.
|
||||||
|
if _, err := t.Exec(ctx, peer, "test -f /etc/orca/step-ca-root.crt"); err != nil {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:ca", Result: "WARN", Message: "/etc/orca/step-ca-root.crt missing (TLS not configured)"})
|
||||||
|
} else {
|
||||||
|
results = append(results, ingressCheckResult{Name: "ingress:ca", Result: "PASS", Message: "step-ca root CA present"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
doctorCmd.AddCommand(doctorIngressCmd)
|
||||||
|
}
|
||||||
+166
-4
@@ -1,18 +1,22 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||||
@@ -180,15 +184,85 @@ func joinProxmox(cmd *cobra.Command) error {
|
|||||||
return fmt.Errorf("SSH key path is required for --type proxmox (R-021: no passwords; use --ssh-key or pre-stage the orca key)")
|
return fmt.Errorf("SSH key path is required for --type proxmox (R-021: no passwords; use --ssh-key or pre-stage the orca key)")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Default ingress mode to "native" if not specified (R-024).
|
// Default ingress mode to "native" if not specified (R-024).
|
||||||
effectiveIngressMode := ingressMode
|
effectiveIngressMode := ingressMode
|
||||||
if effectiveIngressMode == "" {
|
if effectiveIngressMode == "" {
|
||||||
effectiveIngressMode = "native"
|
if !jsonOutput {
|
||||||
|
// Interactive mode: prompt for ingress mode.
|
||||||
|
fmt.Fprint(cmd.OutOrStdout(), "Ingress mode [native/floating-ip] (default native): ")
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if scanner.Scan() {
|
||||||
|
input := strings.TrimSpace(scanner.Text())
|
||||||
|
if input == "floating-ip" {
|
||||||
|
effectiveIngressMode = "floating-ip"
|
||||||
|
} else {
|
||||||
|
effectiveIngressMode = "native"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
effectiveIngressMode = "native"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
effectiveIngressMode = "native"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Floating-IP mode: prompt for params if not provided.
|
||||||
|
effectiveFloatingIP := floatingIP
|
||||||
|
effectiveGateway := gateway
|
||||||
|
effectiveMAC := macAddr
|
||||||
|
if effectiveIngressMode == "floating-ip" {
|
||||||
|
if effectiveFloatingIP == "" && !jsonOutput {
|
||||||
|
fmt.Fprint(cmd.OutOrStdout(), "Floating IP: ")
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if scanner.Scan() {
|
||||||
|
effectiveFloatingIP = strings.TrimSpace(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if effectiveGateway == "" && !jsonOutput {
|
||||||
|
fmt.Fprint(cmd.OutOrStdout(), "Gateway: ")
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if scanner.Scan() {
|
||||||
|
effectiveGateway = strings.TrimSpace(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if effectiveMAC == "" && !jsonOutput {
|
||||||
|
// D-261: auto-generate a random locally-administered MAC.
|
||||||
|
generated, err := proxmox.GenerateRandomMAC()
|
||||||
|
if err == nil {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Generated MAC: %s (press enter to accept, or type your own): ", generated)
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if scanner.Scan() {
|
||||||
|
input := strings.TrimSpace(scanner.Text())
|
||||||
|
if input != "" {
|
||||||
|
effectiveMAC = input
|
||||||
|
} else {
|
||||||
|
effectiveMAC = generated
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
effectiveMAC = generated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate floating-IP mode params.
|
||||||
|
if effectiveIngressMode == "floating-ip" {
|
||||||
|
if net.ParseIP(effectiveFloatingIP) == nil {
|
||||||
|
return fmt.Errorf("--floating-ip %q is not a valid IP", effectiveFloatingIP)
|
||||||
|
}
|
||||||
|
if net.ParseIP(effectiveGateway) == nil {
|
||||||
|
return fmt.Errorf("--gateway %q is not a valid IP", effectiveGateway)
|
||||||
|
}
|
||||||
|
if _, err := net.ParseMAC(effectiveMAC); err != nil {
|
||||||
|
return fmt.Errorf("--mac %q is not a valid MAC: %w", effectiveMAC, err)
|
||||||
|
}
|
||||||
|
if netPrefix < 8 || netPrefix > 32 {
|
||||||
|
return fmt.Errorf("--net-prefix %d must be 8-32", netPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(cmd.Context(), 180*time.Second) // 3min for LXC creation
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
|
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
|
||||||
Host: joinHost,
|
Host: joinHost,
|
||||||
SSHUser: joinSSHUser,
|
SSHUser: joinSSHUser,
|
||||||
@@ -229,6 +303,54 @@ func joinProxmox(cmd *cobra.Command) error {
|
|||||||
if err := registry.Join(regCtx, node); err != nil {
|
if err := registry.Join(regCtx, node); err != nil {
|
||||||
return fmt.Errorf("register proxmox node: %w", err)
|
return fmt.Errorf("register proxmox node: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Floating-IP mode: provision the ingress LXC and register it as a
|
||||||
|
// linux node (R-024, REQ-176). The PVE host is registered as
|
||||||
|
// proxmox (above); the ingress LXC is registered as linux so
|
||||||
|
// `orca job run` pushes traefik dynamic config to it.
|
||||||
|
if effectiveIngressMode == "floating-ip" {
|
||||||
|
lxcLog := newLogger()
|
||||||
|
// Build a runRemote function from the proxmox bootstrap result.
|
||||||
|
// We need SSH access to the PVE host to run pct commands.
|
||||||
|
lxcCtx, lxcCancel := context.WithTimeout(ctx, 120*time.Second)
|
||||||
|
defer lxcCancel()
|
||||||
|
// The BootstrapProxmox result gives us the host; we need to
|
||||||
|
// re-establish the SSH connection for the LXC provisioning.
|
||||||
|
lxcExecFn, lxcErr := proxmoxRemoteExecFn(result, sshKeyPath, joinSSHUser, joinSSHPort)
|
||||||
|
if lxcErr != nil {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Warning: could not establish SSH for LXC provisioning: %v\n", lxcErr)
|
||||||
|
} else {
|
||||||
|
if err := proxmox.ProvisionIngressLXC(lxcCtx, lxcExecFn, proxmox.FloatingIPOptions{
|
||||||
|
FloatingIP: effectiveFloatingIP,
|
||||||
|
Gateway: effectiveGateway,
|
||||||
|
MAC: effectiveMAC,
|
||||||
|
NetPrefix: netPrefix,
|
||||||
|
LXCTemplate: joinLXCTemplate,
|
||||||
|
}, lxcLog); err != nil {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Warning: ingress LXC provisioning failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
// Register the ingress LXC as a linux node.
|
||||||
|
ingressNode := &model.Node{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: "ingress",
|
||||||
|
Address: fmt.Sprintf("%s:8443", effectiveFloatingIP),
|
||||||
|
State: model.NodeStateReady,
|
||||||
|
JoinedAt: time.Now().UTC(),
|
||||||
|
LastSeen: time.Now().UTC(),
|
||||||
|
Kind: string(model.NodeKindLinux),
|
||||||
|
OS: "linux",
|
||||||
|
IngressMode: "floating-ip",
|
||||||
|
}
|
||||||
|
if err := registry.Join(regCtx, ingressNode); err != nil {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Warning: register ingress node: %v\n", err)
|
||||||
|
}
|
||||||
|
if !jsonOutput {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Ingress LXC joined: %s (%s) at %s\n", ingressNode.ID, ingressNode.Name, ingressNode.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// REQ-156 / P07 T5: invalidate the nodes cache.
|
// REQ-156 / P07 T5: invalidate the nodes cache.
|
||||||
cacheInvalidate(cacheNodeClass)
|
cacheInvalidate(cacheNodeClass)
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -239,6 +361,46 @@ func joinProxmox(cmd *cobra.Command) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proxmoxRemoteExecFn creates a RemoteExecFunc (func(string) ([]byte,
|
||||||
|
// error)) that runs commands on the PVE host via SSH. Used by the
|
||||||
|
// floating-IP LXC provisioning path (ProvisionIngressLXC).
|
||||||
|
func proxmoxRemoteExecFn(result *proxmox.Result, sshKeyPath, sshUser string, sshPort int) (func(string) ([]byte, error), error) {
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: sshUser,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
if sshKeyPath != "" {
|
||||||
|
keyData, err := os.ReadFile(sshKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read SSH key: %w", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse SSH key: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||||
|
}
|
||||||
|
addr := result.NodeName
|
||||||
|
if sshPort != 22 {
|
||||||
|
addr = fmt.Sprintf("%s:%d", result.NodeName, sshPort)
|
||||||
|
} else {
|
||||||
|
addr = fmt.Sprintf("%s:%d", result.NodeName, sshPort)
|
||||||
|
}
|
||||||
|
client, err := ssh.Dial("tcp", addr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
return func(cmd string) ([]byte, error) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
return session.CombinedOutput(cmd)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// joinLinux bootstraps a remote generic Linux worker via SSH and
|
// joinLinux bootstraps a remote generic Linux worker via SSH and
|
||||||
// registers it as an orca node (REQ-161, P12). Uses SSH key auth
|
// registers it as an orca node (REQ-161, P12). Uses SSH key auth
|
||||||
// (R-021: no passwords).
|
// (R-021: no passwords).
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FloatingIPOptions carries the parameters for provisioning a
|
||||||
|
// floating-IP ingress LXC (R-024, REQ-176).
|
||||||
|
type FloatingIPOptions struct {
|
||||||
|
// FloatingIP is the public IP assigned to the LXC's eth0.
|
||||||
|
FloatingIP string
|
||||||
|
// Gateway is the default gateway for the LXC.
|
||||||
|
Gateway string
|
||||||
|
// MAC is the MAC address for the LXC's net0 interface.
|
||||||
|
MAC string
|
||||||
|
// NetPrefix is the CIDR prefix for the floating IP (8-32).
|
||||||
|
NetPrefix int
|
||||||
|
// LXCTemplate is the LXC template (default "ubuntu-24.04").
|
||||||
|
LXCTemplate string
|
||||||
|
// VMID is the LXC container ID (default "201" for the ingress LXC).
|
||||||
|
VMID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionIngressLXC creates an Ubuntu LXC named "ingress" that owns
|
||||||
|
// the floating IP, installs podman + orca-traefik inside it, applies nft
|
||||||
|
// DNAT+SNAT inside the LXC, and returns the LXC's IP for node
|
||||||
|
// registration (R-024, REQ-176).
|
||||||
|
//
|
||||||
|
// The LXC is created with:
|
||||||
|
//
|
||||||
|
// --unprivileged 1 --features nesting=1,keyctl=1,fuse=1
|
||||||
|
// --net0 name=eth0,bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway>
|
||||||
|
// --onboot 1
|
||||||
|
//
|
||||||
|
// Inside the LXC, the complete ingress stack is set up: podman
|
||||||
|
// installed, traefik static config written, nft DNAT:443→127.0.0.1:8443
|
||||||
|
// + postrouting masquerade applied, orca-traefik podman container
|
||||||
|
// running with --network host.
|
||||||
|
//
|
||||||
|
// Idempotent: if the LXC already exists, it is not re-created (C-53).
|
||||||
|
func ProvisionIngressLXC(ctx context.Context, runRemote func(string) ([]byte, error), opts FloatingIPOptions, log *slog.Logger) error {
|
||||||
|
template := opts.LXCTemplate
|
||||||
|
if template == "" {
|
||||||
|
template = "ubuntu-24.04"
|
||||||
|
}
|
||||||
|
vmid := opts.VMID
|
||||||
|
if vmid == "" {
|
||||||
|
vmid = "201"
|
||||||
|
}
|
||||||
|
if opts.NetPrefix == 0 {
|
||||||
|
opts.NetPrefix = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields.
|
||||||
|
if opts.FloatingIP == "" || opts.Gateway == "" || opts.MAC == "" {
|
||||||
|
return fmt.Errorf("ingress_lxc: floating-ip, gateway, and mac are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the template is downloaded.
|
||||||
|
_, _ = runRemote(fmt.Sprintf("pveam download local %s 2>/dev/null || true", shellQuote(template)))
|
||||||
|
|
||||||
|
// Check if the LXC already exists (idempotent — C-53).
|
||||||
|
existOut, _ := runRemote(fmt.Sprintf("pct status %s 2>/dev/null || echo absent", vmid))
|
||||||
|
existStr := strings.TrimSpace(string(existOut))
|
||||||
|
if existStr == "absent" {
|
||||||
|
log.Info("ingress_lxc.creating", "vmid", vmid, "hostname", "ingress", "ip", opts.FloatingIP)
|
||||||
|
net0 := fmt.Sprintf("name=eth0,bridge=vmbr0,hwaddr=%s,ip=%s/%d,gw=%s",
|
||||||
|
opts.MAC, opts.FloatingIP, opts.NetPrefix, opts.Gateway)
|
||||||
|
createCmd := fmt.Sprintf(
|
||||||
|
"pct create %s local:vztmpl/%s --hostname ingress --unprivileged 1 --features nesting=1,keyctl=1,fuse=1 --net0 %s --onboot 1 --memory 2048 --swap 0 --rootfs local:8 2>&1",
|
||||||
|
vmid, shellQuote(template), net0,
|
||||||
|
)
|
||||||
|
if out, err := runRemote(createCmd); err != nil {
|
||||||
|
return fmt.Errorf("pct create ingress LXC: %w (output: %s)", err, string(out))
|
||||||
|
}
|
||||||
|
if out, err := runRemote(fmt.Sprintf("pct start %s", vmid)); err != nil {
|
||||||
|
return fmt.Errorf("pct start ingress LXC: %w (output: %s)", err, string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for LXC network (retry for up to 60s).
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
ipOut, _ := runRemote(fmt.Sprintf("pct exec %s -- hostname -I 2>/dev/null", vmid))
|
||||||
|
ipStr := strings.TrimSpace(string(ipOut))
|
||||||
|
if ipStr != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
log.Info("ingress_lxc.network_ready", "vmid", vmid, "ip", opts.FloatingIP)
|
||||||
|
|
||||||
|
// Install podman inside the LXC (C-53: idempotent).
|
||||||
|
_, _ = runRemote(fmt.Sprintf(
|
||||||
|
"pct exec %s -- bash -c 'command -v podman >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs nftables 2>&1)' 2>&1",
|
||||||
|
vmid,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Enable podman-restart.service inside the LXC (research Topic 6).
|
||||||
|
_, _ = runRemote(fmt.Sprintf("pct exec %s -- systemctl enable --now podman-restart.service 2>/dev/null", vmid))
|
||||||
|
|
||||||
|
// Push step-ca root CA into the LXC (C-60: CACertPath).
|
||||||
|
caPath := certpaths.CACertPath()
|
||||||
|
caData, caErr := os.ReadFile(caPath)
|
||||||
|
if caErr != nil {
|
||||||
|
caData = []byte{}
|
||||||
|
}
|
||||||
|
caDelim := "EOF_CA"
|
||||||
|
_, _ = runRemote(fmt.Sprintf(
|
||||||
|
"pct exec %s -- bash -c 'mkdir -p /etc/orca && cat > /etc/orca/step-ca-root.crt <<%s\\n%s\\n%s'",
|
||||||
|
vmid, caDelim, string(caData), caDelim,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Render + write traefik static config inside the LXC (C-58).
|
||||||
|
staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{})
|
||||||
|
if err == nil {
|
||||||
|
for _, f := range staticFiles {
|
||||||
|
delim := "EOF_TF"
|
||||||
|
_, _ = runRemote(fmt.Sprintf(
|
||||||
|
"pct exec %s -- bash -c 'mkdir -p /etc/traefik/dynamic && cat > %s <<%s\\n%s\\n%s'",
|
||||||
|
vmid, f.Path, delim, f.Content, delim,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render + apply nft DNAT+SNAT INSIDE the LXC (DNATTarget =
|
||||||
|
// 127.0.0.1 — traefik runs with --network host inside the LXC).
|
||||||
|
nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
|
||||||
|
if err == nil {
|
||||||
|
for _, f := range nftFiles {
|
||||||
|
delim := "EOF_NF"
|
||||||
|
_, _ = runRemote(fmt.Sprintf(
|
||||||
|
"pct exec %s -- bash -c 'mkdir -p /etc/nftables.d && cat > %s <<%s\\n%s\\n%s'",
|
||||||
|
vmid, f.Path, delim, f.Content, delim,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_, _ = runRemote(fmt.Sprintf("pct exec %s -- nft add table inet orca-ingress 2>/dev/null || true", vmid))
|
||||||
|
_, _ = runRemote(fmt.Sprintf("pct exec %s -- nft -f /etc/nftables.d/orca.nft 2>&1", vmid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure podman orca-traefik container inside the LXC.
|
||||||
|
traefikExecFn := func(cmd string) ([]byte, error) {
|
||||||
|
return runRemote(fmt.Sprintf("pct exec %s -- bash -c %s 2>&1", vmid, shellQuote(cmd)))
|
||||||
|
}
|
||||||
|
if err := traefik.EnsureTraefikContainerRemote(ctx, "", traefikExecFn); err != nil {
|
||||||
|
log.Warn("ingress_lxc.traefik_failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("ingress_lxc.provisioned", "vmid", vmid, "ip", opts.FloatingIP)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRandomMAC generates a random locally-administered MAC address
|
||||||
|
// (02:XX:XX:XX:XX:XX) for use as the LXC net0 hardware address when the
|
||||||
|
// operator does not provide one (D-261).
|
||||||
|
func GenerateRandomMAC() (string, error) {
|
||||||
|
b := make([]byte, 5)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("generate MAC: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("02:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4]), nil
|
||||||
|
}
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ci-release.sh — Build and upload release assets to Gitea.
|
||||||
|
# Called by .gitea/workflows/release.yml as a separate step AFTER
|
||||||
|
# `coreci run` completes. This runs in the Gitea Actions runner directly
|
||||||
|
# (not inside CoreCI's shell-isolated executor), so it has full env
|
||||||
|
# access and no disk-space constraints from CoreCI's SQLite logging.
|
||||||
|
#
|
||||||
|
# Environment variables (from Gitea Actions step env):
|
||||||
|
# GITEA_TOKEN — Gitea API token (from PAT_TOKEN secret)
|
||||||
|
# VERSION — tag name (from gitea.ref_name)
|
||||||
|
# GIT_COMMIT — commit SHA (from gitea.sha)
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-https://git.cloudinit.dev}"
|
||||||
|
GITEA_OWNER="${GITEA_OWNER:-coreci}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-orca}"
|
||||||
|
|
||||||
|
info() { echo "ci-release: $*"; }
|
||||||
|
err() { echo "ci-release: error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then err "GITEA_TOKEN is not set"; fi
|
||||||
|
if [ -z "${VERSION:-}" ]; then err "VERSION is not set"; fi
|
||||||
|
|
||||||
|
GIT_COMMIT="${GIT_COMMIT:-unknown}"
|
||||||
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
TARBALL="orca-${VERSION}-linux-amd64.tar.gz"
|
||||||
|
|
||||||
|
info "building release ${VERSION} (commit $(echo "${GIT_COMMIT}" | cut -c1-12))..."
|
||||||
|
|
||||||
|
# Build the release binary with version injection.
|
||||||
|
LDFLAGS="-s -w \
|
||||||
|
-X git.cloudinit.dev/coreci/orca/internal/cli.version=${VERSION} \
|
||||||
|
-X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=${GIT_COMMIT} \
|
||||||
|
-X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=${BUILD_TIME}"
|
||||||
|
mkdir -p bin
|
||||||
|
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca 2>&1 || err "go build failed"
|
||||||
|
|
||||||
|
# Package the tarball and checksums.
|
||||||
|
tar -czf "${TARBALL}" -C bin orca || err "tar failed"
|
||||||
|
sha256sum "${TARBALL}" > SHA256SUMS || err "sha256sum failed"
|
||||||
|
info "built ${TARBALL} ($(wc -c < "${TARBALL}") bytes)"
|
||||||
|
|
||||||
|
# Check if the release already exists (the CIAgent ship workflow may
|
||||||
|
# have created it with title+body but no binary assets).
|
||||||
|
info "checking for existing release ${VERSION}..."
|
||||||
|
RELEASE_ID=$(curl -fsSL \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${VERSION}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
| python3 -c "import json,sys; r=json.load(sys.stdin); print(r.get('id',''))" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
info "creating new release ${VERSION}..."
|
||||||
|
RELEASE_ID=$(curl -fsSL -X POST \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${VERSION}\",\"name\":\"Orca ${VERSION}\",\"body\":\"Release ${VERSION} built by CoreCI pipeline\"}" \
|
||||||
|
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
err "failed to create release ${VERSION}"
|
||||||
|
fi
|
||||||
|
info "created release ID ${RELEASE_ID}"
|
||||||
|
else
|
||||||
|
info "release ${VERSION} already exists (ID ${RELEASE_ID}) — attaching assets"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Attach tarball and SHA256SUMS to the release.
|
||||||
|
info "attaching ${TARBALL} to release ${RELEASE_ID}..."
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID}/assets?name=${TARBALL}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-F "attachment=@${TARBALL}" 2>&1 || err "failed to attach ${TARBALL}"
|
||||||
|
|
||||||
|
info "attaching SHA256SUMS to release ${RELEASE_ID}..."
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID}/assets?name=SHA256SUMS" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-F "attachment=@SHA256SUMS" 2>&1 || err "failed to attach SHA256SUMS"
|
||||||
|
|
||||||
|
# Verify assets are actually attached (REQ-097, gate C-21).
|
||||||
|
# Use the /releases/{id}/assets endpoint (not /releases/tags/{tag}) because
|
||||||
|
# the tag endpoint may have a caching delay showing 0 assets even after
|
||||||
|
# successful upload.
|
||||||
|
sleep 3
|
||||||
|
ASSET_COUNT=$(curl -fsSL \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
| python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
|
||||||
|
info "release ${VERSION} has ${ASSET_COUNT} assets"
|
||||||
|
if [ "${ASSET_COUNT}" -lt 2 ]; then
|
||||||
|
err "assets not attached after upload (REQ-097, C-21) — got ${ASSET_COUNT}"
|
||||||
|
fi
|
||||||
|
info "release ${VERSION} published with ${ASSET_COUNT} binary assets"
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ci-run.sh — CoreCI pipeline runner for orca.
|
||||||
|
# Called by .coreci.yml jobs via: sh scripts/ci-run.sh <job-name>
|
||||||
|
#
|
||||||
|
# CoreCI's ValidateShellCommand forbids shell metacharacters (&|;`><$())
|
||||||
|
# in the invoke: string. This script wraps the complex logic so the
|
||||||
|
# invoke: field is just "sh scripts/ci-run.sh <job-name>".
|
||||||
|
#
|
||||||
|
# Environment variables (provided by CoreCI's CI context + PassThroughEnv):
|
||||||
|
# CI_COMMIT_BRANCH — tag name on tag pushes (from GITHUB_REF_NAME)
|
||||||
|
# CI_COMMIT_SHA — commit SHA
|
||||||
|
# GITEA_TOKEN — Gitea API token (from Gitea Actions secret PAT_TOKEN)
|
||||||
|
#
|
||||||
|
# NOTE: uses #!/bin/sh — do NOT use bash-only features (pipefail, [[ ]], etc.)
|
||||||
|
# The Gitea Actions runner uses dash as /bin/sh.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
JOB="${1:-}"
|
||||||
|
if [ -z "$JOB" ]; then
|
||||||
|
echo "usage: sh scripts/ci-run.sh <job-name>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CoreCI's shell-isolated executor (buildIsolatedEnv) does NOT forward
|
||||||
|
# Go toolchain env vars (GOROOT, GOPATH, GOCACHE, GOMODCACHE are in the
|
||||||
|
# systemVars deny-list). Re-derive them from the `go` binary on PATH so
|
||||||
|
# Go commands work in the shell-isolated executor.
|
||||||
|
echo "ci-run: PATH=$PATH" >&2
|
||||||
|
echo "ci-run: which go=$(command -v go 2>/dev/null || echo 'not found')" >&2
|
||||||
|
if command -v go >/dev/null 2>&1; then
|
||||||
|
export GOROOT="${GOROOT:-$(go env GOROOT 2>/dev/null || echo "")}"
|
||||||
|
export GOPATH="${GOPATH:-$(go env GOPATH 2>/dev/null || echo "$HOME/go")}"
|
||||||
|
export GOCACHE="${GOCACHE:-$(go env GOCACHE 2>/dev/null || echo "$HOME/.cache/go-build")}"
|
||||||
|
export GOMODCACHE="${GOMODCACHE:-$(go env GOMODCACHE 2>/dev/null || echo "$HOME/go/pkg/mod")}"
|
||||||
|
echo "ci-run: GOROOT=$GOROOT GOPATH=$GOPATH GOCACHE=$GOCACHE GOMODCACHE=$GOMODCACHE" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { echo "ci-run: $*"; }
|
||||||
|
err() { echo "ci-run: error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
case "$JOB" in
|
||||||
|
# ── validate ──────────────────────────────────────────────────────
|
||||||
|
go-vet)
|
||||||
|
go version
|
||||||
|
gofmt -l .
|
||||||
|
go vet ./...
|
||||||
|
;;
|
||||||
|
|
||||||
|
verify-reqs)
|
||||||
|
make verify-reqs
|
||||||
|
;;
|
||||||
|
|
||||||
|
gosec)
|
||||||
|
go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||||
|
gosec -fmt text -quiet ./...
|
||||||
|
;;
|
||||||
|
|
||||||
|
govulncheck)
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||||
|
govulncheck -mode binary ./...
|
||||||
|
;;
|
||||||
|
|
||||||
|
gitleaks)
|
||||||
|
curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks-linux-amd64.tar.gz -o /tmp/gitleaks.tar.gz
|
||||||
|
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||||
|
mv /tmp/gitleaks /usr/local/bin/gitleaks 2>/dev/null || cp /tmp/gitleaks ./gitleaks
|
||||||
|
chmod +x ./gitleaks 2>/dev/null || true
|
||||||
|
if [ -x ./gitleaks ]; then
|
||||||
|
./gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||||
|
else
|
||||||
|
gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
# ── build ──────────────────────────────────────────────────────────
|
||||||
|
build)
|
||||||
|
info "building orca binary..."
|
||||||
|
VERSION="${CI_COMMIT_BRANCH:-dev}"
|
||||||
|
GIT_COMMIT="${CI_COMMIT_SHA:-unknown}"
|
||||||
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
info "VERSION=$VERSION GIT_COMMIT=$GIT_COMMIT BUILD_TIME=$BUILD_TIME"
|
||||||
|
LDFLAGS="-s -w \
|
||||||
|
-X git.cloudinit.dev/coreci/orca/internal/cli.version=${VERSION} \
|
||||||
|
-X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=${GIT_COMMIT} \
|
||||||
|
-X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=${BUILD_TIME}"
|
||||||
|
mkdir -p bin
|
||||||
|
info "running: go build -trimpath -ldflags=... -o bin/orca ./cmd/orca"
|
||||||
|
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca 2>&1 || err "go build failed with exit $?"
|
||||||
|
file bin/orca 2>/dev/null || echo "file command not available"
|
||||||
|
./bin/orca version 2>&1 || echo "orca version failed"
|
||||||
|
;;
|
||||||
|
|
||||||
|
# ── test (REQ-031: -race) ─────────────────────────────────────────
|
||||||
|
test)
|
||||||
|
go test -race -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -func=coverage.out | tail -1
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
err "unknown job: ${JOB}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -164,6 +164,7 @@ if [ -z "$ASSET_URL" ]; then
|
|||||||
ASSET_URL="$(echo "$FALLBACK_OUT" | head -1)"
|
ASSET_URL="$(echo "$FALLBACK_OUT" | head -1)"
|
||||||
if [ -n "$ASSET_URL" ]; then
|
if [ -n "$ASSET_URL" ]; then
|
||||||
VERSION="$(echo "$FALLBACK_OUT" | tail -1)"
|
VERSION="$(echo "$FALLBACK_OUT" | tail -1)"
|
||||||
|
TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
|
||||||
info "WARNING: falling back to ${VERSION} which has orca-${VERSION}-${OS}-${ARCH}.tar.gz."
|
info "WARNING: falling back to ${VERSION} which has orca-${VERSION}-${OS}-${ARCH}.tar.gz."
|
||||||
else
|
else
|
||||||
err "could not find any release with a ${OS}-${ARCH} tarball in the last 50 releases. Check that a release exists with a linux-${ARCH} binary."
|
err "could not find any release with a ${OS}-${ARCH} tarball in the last 50 releases. Check that a release exists with a linux-${ARCH} binary."
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ load test_helper
|
|||||||
assert_contains "$output" "dry-run (--check)"
|
assert_contains "$output" "dry-run (--check)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "install.sh fallback walk syncs TARBALL to fallback version (REQ-132 regression)" {
|
||||||
|
# Regression guard: when the fallback walk reassigns VERSION, the
|
||||||
|
# TARBALL variable must be recomputed too. v0.14.2 is a release with
|
||||||
|
# no binary asset; the installer must walk back to an earlier release.
|
||||||
|
# The dry-run "would install" line must reference the SAME fallback
|
||||||
|
# version as the "falling back to" line — not the stale pinned one.
|
||||||
|
# Before the fix, TARBALL stayed at the pinned v0.14.2 name while
|
||||||
|
# VERSION became the fallback, causing grep|sha256sum to see no
|
||||||
|
# matching checksum line and REQ-132 to refuse install.
|
||||||
|
skip_if_no_network
|
||||||
|
run timeout 60 "$SCRIPTS_DIR/install.sh" --check --version v0.14.2
|
||||||
|
assert_status 0 "$status"
|
||||||
|
assert_contains "$output" "falling back"
|
||||||
|
# Capture the fallback version from the "falling back to vX.Y.Z" line.
|
||||||
|
fb_version="$(printf '%s\n' "$output" | sed -n 's/.*falling back to \(v[0-9][0-9.]*\).*/\1/p' | head -1)"
|
||||||
|
[ -n "$fb_version" ] || { echo "could not parse fallback version from output: $output" >&2; return 1; }
|
||||||
|
# The dry-run "would install" line must use the fallback version,
|
||||||
|
# proving VERSION and TARBALL are in sync (not the stale pinned v0.14.2).
|
||||||
|
assert_contains "$output" "would install: orca ${fb_version}"
|
||||||
|
# And it must NOT reference the stale pinned version in the install line.
|
||||||
|
assert_not_contains "$output" "would install: orca v0.14.2"
|
||||||
|
}
|
||||||
|
|
||||||
@test "install.sh rejects unknown arguments" {
|
@test "install.sh rejects unknown arguments" {
|
||||||
run "$SCRIPTS_DIR/install.sh" --bogus-flag
|
run "$SCRIPTS_DIR/install.sh" --bogus-flag
|
||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ fi
|
|||||||
while read local_ref local_sha remote_ref remote_sha; do
|
while read local_ref local_sha remote_ref remote_sha; do
|
||||||
branch="${remote_ref#refs/heads/}"
|
branch="${remote_ref#refs/heads/}"
|
||||||
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
|
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
|
||||||
|
# Check if this is a tag push (refs/tags/*)
|
||||||
|
tag="${remote_ref#refs/tags/}"
|
||||||
|
if [ -n "$tag" ] && [ "$tag" != "$remote_ref" ]; then
|
||||||
|
echo "→ Triggering CoreCI for tag: $tag (${local_sha:0:7})"
|
||||||
|
payload=$(printf '{"repo":"coreci/orca","branch":"%s","ref":"%s"}' "$tag" "$local_sha")
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
curl -fsS -X POST "${CORECI_URL}/api/pipeline/run" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" >/dev/null 2>&1 \
|
||||||
|
&& echo " ✓ CoreCI triggered" \
|
||||||
|
|| echo " (CoreCI trigger failed; Gitea Actions webhook is secondary path)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
echo "→ Triggering CoreCI for branch: $branch (${local_sha:0:7})"
|
echo "→ Triggering CoreCI for branch: $branch (${local_sha:0:7})"
|
||||||
|
|||||||
+25
-2
@@ -145,8 +145,8 @@ assert "34 type_linux_available" \
|
|||||||
assert "35 status_deprecated" \
|
assert "35 status_deprecated" \
|
||||||
'$ORCA status 2>&1 | grep -qi "deprecated"'
|
'$ORCA status 2>&1 | grep -qi "deprecated"'
|
||||||
|
|
||||||
assert "36 traefik_installed" \
|
assert "36 traefik_container_running" \
|
||||||
'systemctl is-active orca-traefik 2>/dev/null | grep -q "active" || exit 77'
|
'podman inspect --format "{{.State.Running}}" orca-traefik 2>/dev/null | grep -q "true" || exit 77'
|
||||||
|
|
||||||
assert "37 known_hosts_exists" \
|
assert "37 known_hosts_exists" \
|
||||||
'test -f "$ORCA_HOME/known_hosts" || test -f "$ORCA_HOME/cluster/known_hosts"'
|
'test -f "$ORCA_HOME/known_hosts" || test -f "$ORCA_HOME/cluster/known_hosts"'
|
||||||
@@ -154,6 +154,29 @@ assert "37 known_hosts_exists" \
|
|||||||
assert "38 master_key_exists" \
|
assert "38 master_key_exists" \
|
||||||
'test -f "$ORCA_HOME/cluster/master.key" || test -f "$ORCA_HOME/cluster/master.key.sealed"'
|
'test -f "$ORCA_HOME/cluster/master.key" || test -f "$ORCA_HOME/cluster/master.key.sealed"'
|
||||||
|
|
||||||
|
# --- v0.14 ingress bootstrap assertions (R-024) ---
|
||||||
|
|
||||||
|
assert "40 ingress_nft_table" \
|
||||||
|
'nft list table inet orca-ingress 2>/dev/null | grep -q "chain prerouting"'
|
||||||
|
|
||||||
|
assert "41 ingress_nft_dnat" \
|
||||||
|
'nft list table inet orca-ingress 2>/dev/null | grep -q "dnat to"'
|
||||||
|
|
||||||
|
assert "42 ingress_nft_snat" \
|
||||||
|
'nft list table inet orca-ingress 2>/dev/null | grep -q "masquerade"'
|
||||||
|
|
||||||
|
assert "43 ingress_dynamic_dir" \
|
||||||
|
'test -d /etc/traefik/dynamic'
|
||||||
|
|
||||||
|
assert "44 ingress_step_ca" \
|
||||||
|
'test -f /etc/orca/step-ca-root.crt'
|
||||||
|
|
||||||
|
assert "45 ingress_traefik_yml" \
|
||||||
|
'test -f /etc/traefik/traefik.yml'
|
||||||
|
|
||||||
|
assert "46 ingress_doctor_pass" \
|
||||||
|
'$ORCA doctor ingress 2>&1 | grep -q "PASS"'
|
||||||
|
|
||||||
# --- Report ---
|
# --- Report ---
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNftEmitter_PostroutingAndDNATTarget (REQ-173) verifies the nft
|
||||||
|
// emitter renders the postrouting masquerade chain and supports
|
||||||
|
// DNATTarget substitution.
|
||||||
|
func TestNftEmitter_PostroutingAndDNATTarget(t *testing.T) {
|
||||||
|
files, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{
|
||||||
|
DNATTarget: "10.99.0.10",
|
||||||
|
EnableSNAT: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderNftConfig: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "chain postrouting") {
|
||||||
|
t.Errorf("missing postrouting chain:\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "masquerade") {
|
||||||
|
t.Errorf("missing masquerade rule:\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "dnat to 10.99.0.10:8443") {
|
||||||
|
t.Errorf("missing custom DNAT target:\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNftEmitter_PriorityMinus10 (research Topic 2) verifies the input
|
||||||
|
// and forward chains use priority -10 for pve-firewall coexistence.
|
||||||
|
func TestNftEmitter_PriorityMinus10(t *testing.T) {
|
||||||
|
files, _ := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "hook input priority -10;") {
|
||||||
|
t.Errorf("input chain should use priority -10:\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "hook forward priority -10;") {
|
||||||
|
t.Errorf("forward chain should use priority -10:\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTraefikEmitter_TLSModel (REQ-172) verifies the dynamic config
|
||||||
|
// emits tls: {} and does NOT contain certResolver (dropped in v0.14).
|
||||||
|
func TestTraefikEmitter_TLSModel(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Name: "test-svc",
|
||||||
|
Kind: "Service",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http"}},
|
||||||
|
}
|
||||||
|
node := &emitter.Node{
|
||||||
|
Hostname: "test-node",
|
||||||
|
}
|
||||||
|
files, err := emitter.TraefikEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "tls: {}") {
|
||||||
|
t.Errorf("missing tls: {} (v0.14 model):\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "certResolver: orca") {
|
||||||
|
t.Errorf("certResolver: orca should be removed (v0.14):\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTraefikImageRef verifies the image reference resolution for the
|
||||||
|
// orca-traefik podman container.
|
||||||
|
func TestTraefikImageRef(t *testing.T) {
|
||||||
|
ref := traefik.ImageRef("v0.13.7")
|
||||||
|
want := "git.cloudinit.dev/coreci/orca-traefik:v0.13.7"
|
||||||
|
if ref != want {
|
||||||
|
t.Errorf("ImageRef(v0.13.7) = %q, want %q", ref, want)
|
||||||
|
}
|
||||||
|
// Dev build falls back to latest.
|
||||||
|
ref = traefik.ImageRef("dev")
|
||||||
|
if ref != "git.cloudinit.dev/coreci/orca-traefik:latest" {
|
||||||
|
t.Errorf("ImageRef(dev) = %q, want latest", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxmox_FloatingIP_LXC_ProvisioningCommands (REQ-176) verifies
|
||||||
|
// the ProvisionIngressLXC function sends the correct pct create
|
||||||
|
// command with the right net0 parameters.
|
||||||
|
func TestProxmox_FloatingIP_LXC_ProvisioningCommands(t *testing.T) {
|
||||||
|
var cmds []string
|
||||||
|
execFn := func(cmd string) ([]byte, error) {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
// Simulate: pct status returns "absent" on first call, then OK.
|
||||||
|
if strings.Contains(cmd, "pct status 201") {
|
||||||
|
return []byte("absent\n"), nil
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd, "pct create") {
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd, "pct start 201") {
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd, "hostname -I") {
|
||||||
|
return []byte("203.0.113.10\n"), nil
|
||||||
|
}
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
err := proxmox.ProvisionIngressLXC(nil, execFn, proxmox.FloatingIPOptions{
|
||||||
|
FloatingIP: "203.0.113.10",
|
||||||
|
Gateway: "203.0.113.1",
|
||||||
|
MAC: "02:01:02:03:04:05",
|
||||||
|
NetPrefix: 24,
|
||||||
|
LXCTemplate: "ubuntu-24.04",
|
||||||
|
}, slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionIngressLXC: %v", err)
|
||||||
|
}
|
||||||
|
// Verify pct create has the right net0 params.
|
||||||
|
foundCreate := false
|
||||||
|
for _, c := range cmds {
|
||||||
|
if strings.Contains(c, "pct create") {
|
||||||
|
foundCreate = true
|
||||||
|
if !strings.Contains(c, "hostname ingress") {
|
||||||
|
t.Errorf("pct create missing hostname ingress: %s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "hwaddr=02:01:02:03:04:05") {
|
||||||
|
t.Errorf("pct create missing hwaddr: %s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "ip=203.0.113.10/24") {
|
||||||
|
t.Errorf("pct create missing ip: %s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "gw=203.0.113.1") {
|
||||||
|
t.Errorf("pct create missing gw: %s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "nesting=1,keyctl=1,fuse=1") {
|
||||||
|
t.Errorf("pct create missing features (research Topic 3): %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCreate {
|
||||||
|
t.Errorf("pct create command not sent\ncommands: %v", cmds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxmox_GenerateRandomMAC (D-261) verifies MAC generation produces
|
||||||
|
// a valid locally-administered MAC.
|
||||||
|
func TestProxmox_GenerateRandomMAC(t *testing.T) {
|
||||||
|
mac, err := proxmox.GenerateRandomMAC()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateRandomMAC: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(mac, "02:") {
|
||||||
|
t.Errorf("MAC should start with 02: (locally administered): %s", mac)
|
||||||
|
}
|
||||||
|
// Verify it's 6 octets.
|
||||||
|
parts := strings.Split(mac, ":")
|
||||||
|
if len(parts) != 6 {
|
||||||
|
t.Errorf("MAC should have 6 octets: %s", mac)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user