2026-04-11 00:17:10 -03:00
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRefreshDevicesAndReplacementWorkflow ( t * testing . T ) {
kube := fakeKubeServer ( t )
harbor := fakeHarborServer ( t , true )
app := newTestApp ( t )
app . settings . Namespace = "maintenance"
app . settings . RunnerImageARM64 = "runner:arm64"
app . settings . HarborAPIBase = harbor . URL + "/api/v2.0"
app . settings . HarborUsername = "admin"
app . settings . HarborPassword = "pw"
app . settings . HarborProject = "metis"
app . settings . HarborRegistry = "registry.example"
app . settings . ArtifactStatePath = filepath . Join ( t . TempDir ( ) , "artifacts.json" )
installKubeFactory ( t , kube )
devices , err := app . RefreshDevices ( "titan-22" )
if err != nil {
t . Fatalf ( "RefreshDevices: %v" , err )
}
if len ( devices ) < 2 || devices [ 0 ] . Path != "/dev/sdz" {
t . Fatalf ( "unexpected devices: %+v" , devices )
}
cached , err := app . ListDevices ( "titan-22" )
if err != nil || len ( cached ) != len ( devices ) {
t . Fatalf ( "ListDevices cache mismatch: %+v err=%v" , cached , err )
}
state := app . State ( "titan-22" )
if state . PreferredDevice != "/dev/sdz" {
t . Fatalf ( "expected preferred device /dev/sdz, got %q" , state . PreferredDevice )
}
job , err := app . Replace ( "titan-15" , "titan-22" , "/dev/sdz" )
if err != nil {
t . Fatalf ( "Replace: %v" , err )
}
waitForJobState ( t , app , job . ID , JobDone )
if got := app . job ( job . ID ) ; got == nil || got . Status != JobDone {
t . Fatalf ( "replace job did not finish successfully: %#v" , got )
}
if got := app . artifacts ( ) [ "titan-15" ] . Ref ; got != "registry.example/metis/titan-15:latest" {
t . Fatalf ( "artifact not recorded: %q" , got )
}
}
func TestRemotePodStateAndLogsHelpers ( t * testing . T ) {
kube := fakeKubeServer ( t )
installKubeFactory ( t , kube )
app := newTestApp ( t )
app . settings . Namespace = "maintenance"
client , err := kubeClientFactory ( )
if err != nil {
t . Fatalf ( "kube client: %v" , err )
}
state , err := app . remotePodState ( client , "metis-build-test" )
if err != nil {
t . Fatalf ( "remotePodState: %v" , err )
}
if state . Name != "metis-build-test" || state . Reason != "Completed" || ! strings . Contains ( state . Message , "build" ) {
t . Fatalf ( "unexpected pod state: %#v" , state )
}
logs , err := app . remotePodLogs ( client , "metis-build-test" )
if err != nil || ! strings . Contains ( logs , "build logs" ) {
t . Fatalf ( "remotePodLogs: logs=%q err=%v" , logs , err )
}
}
func TestHarborProjectCreationAndPrune ( t * testing . T ) {
harbor := fakeHarborServer ( t , false )
app := & App { settings : Settings {
HarborAPIBase : harbor . URL + "/api/v2.0" ,
HarborUsername : "admin" ,
HarborPassword : "pw" ,
HarborProject : "metis" ,
HarborRegistry : "registry.example" ,
} , metrics : NewMetrics ( ) }
if got := app . artifactRepo ( "titan-15" ) ; got != "registry.example/metis/titan-15" {
t . Fatalf ( "artifactRepo = %q" , got )
}
if err := app . ensureHarborProject ( ) ; err != nil {
t . Fatalf ( "ensureHarborProject: %v" , err )
}
if err := app . pruneHarborArtifacts ( "titan-15" , 1 ) ; err != nil {
t . Fatalf ( "pruneHarborArtifacts: %v" , err )
}
}
func TestKubeJSONAndDeleteRequests ( t * testing . T ) {
kube := fakeKubeServer ( t )
client := kubeClientFactoryForURL ( kube . URL , kube . Client ( ) )
var payload map [ string ] any
if err := client . jsonRequest ( http . MethodGet , "/api/v1/nodes" , nil , & payload ) ; err != nil {
t . Fatalf ( "jsonRequest: %v" , err )
}
if err := client . deleteRequest ( "/api/v1/nodes/titan-15" ) ; err != nil {
t . Fatalf ( "deleteRequest: %v" , err )
}
}
func TestBuildStageAndArchiveHelpers ( t * testing . T ) {
if got := remoteArtifactNoteForTest ( t ) ; got != "registry.example/metis/titan-15:latest" {
t . Fatalf ( "remoteArtifactNote = %q" , got )
}
}
func waitForJobState ( t * testing . T , app * App , id string , want JobStatus ) {
t . Helper ( )
deadline := time . Now ( ) . Add ( 5 * time . Second )
for time . Now ( ) . Before ( deadline ) {
if got := app . job ( id ) ; got != nil {
if got . Status == want {
return
}
if got . Status == JobError {
t . Fatalf ( "job %s failed: %s" , id , got . Error )
}
}
time . Sleep ( 10 * time . Millisecond )
}
t . Fatalf ( "job %s never reached state %s" , id , want )
}
func installKubeFactory ( t * testing . T , srv * httptest . Server ) {
t . Helper ( )
orig := kubeClientFactory
kubeClientFactory = func ( ) ( * kubeClient , error ) {
return & kubeClient { baseURL : srv . URL , token : "tok" , client : srv . Client ( ) } , nil
}
t . Cleanup ( func ( ) {
kubeClientFactory = orig
} )
}
func kubeClientFactoryForURL ( baseURL string , client * http . Client ) * kubeClient {
return & kubeClient { baseURL : baseURL , token : "tok" , client : client }
}
func fakeKubeServer ( t * testing . T ) * httptest . Server {
t . Helper ( )
return httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
switch {
case r . Method == http . MethodGet && r . URL . Path == "/api/v1/nodes" :
_ = json . NewEncoder ( w ) . Encode ( map [ string ] any {
"items" : [ ] any {
map [ string ] any {
"metadata" : map [ string ] any {
"name" : "titan-22" ,
"labels" : map [ string ] string {
"kubernetes.io/arch" : "arm64" ,
"hardware" : "rpi5" ,
"node-role.kubernetes.io/worker" : "true" ,
} ,
} ,
"spec" : map [ string ] any { "unschedulable" : false } ,
} ,
} ,
} )
2026-04-19 21:54:51 -03:00
case r . Method == http . MethodGet && r . URL . Path == "/api/v1/namespaces/maintenance/pods" :
_ = json . NewEncoder ( w ) . Encode ( map [ string ] any { "items" : [ ] any { } } )
2026-04-11 00:17:10 -03:00
case r . Method == http . MethodPost && strings . Contains ( r . URL . Path , "/pods" ) :
w . WriteHeader ( http . StatusCreated )
case r . Method == http . MethodDelete && strings . Contains ( r . URL . Path , "/pods/" ) :
w . WriteHeader ( http . StatusOK )
case r . Method == http . MethodDelete && strings . Contains ( r . URL . Path , "/nodes/" ) :
w . WriteHeader ( http . StatusOK )
case r . Method == http . MethodGet && strings . Contains ( r . URL . Path , "/pods/" ) && strings . HasSuffix ( r . URL . Path , "/log" ) :
_ , _ = w . Write ( [ ] byte ( "build logs from kubelet" ) )
case r . Method == http . MethodGet && strings . Contains ( r . URL . Path , "/pods/" ) :
podName := filepath . Base ( strings . TrimSuffix ( r . URL . Path , "/log" ) )
message := ` { } `
switch {
case strings . Contains ( podName , "devices" ) :
2026-04-23 23:36:42 -03:00
message = ` { "devices":[ { "name":"sdz","path":"/dev/sdz","model":"Micro SD","transport":"usb","type":"disk","removable":true,"hotplug":true,"size_bytes":32000000000}, { "name":"tmp","path":"hosttmp:///var/tmp/metis-flash-test","model":"Host scratch","transport":"test","type":"file","note":"Test-only host write target under /var/tmp/metis-flash-test","size_bytes":1}]} `
2026-04-11 00:17:10 -03:00
case strings . Contains ( podName , "build" ) :
message = ` { "local_path":"/workspace/build/titan-15.img.xz","compressed":true,"size_bytes":1234,"build_tag":"build-1"} `
case strings . Contains ( podName , "flash" ) :
2026-04-24 12:09:53 -03:00
message = ` { "dest_path":"/var/tmp/metis-flash-test/titan-15.img","verified":true,"verification_kind":"image-file","verification_summary":"Verified image layout at /var/tmp/metis-flash-test/titan-15.img; boot and writable partitions are present."} `
2026-04-11 00:17:10 -03:00
}
_ = json . NewEncoder ( w ) . Encode ( map [ string ] any {
"metadata" : map [ string ] any { "name" : podName } ,
"status" : map [ string ] any {
"phase" : "Succeeded" ,
"message" : message ,
"reason" : "Completed" ,
} ,
} )
default :
http . NotFound ( w , r )
}
} ) )
}
func fakeHarborServer ( t * testing . T , projectExists bool ) * httptest . Server {
t . Helper ( )
return httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
switch {
case r . Method == http . MethodGet && strings . HasPrefix ( r . URL . Path , "/api/v2.0/projects" ) :
if projectExists {
_ = json . NewEncoder ( w ) . Encode ( [ ] map [ string ] string { { "name" : "metis" } } )
return
}
_ = json . NewEncoder ( w ) . Encode ( [ ] map [ string ] string { } )
case r . Method == http . MethodPost && r . URL . Path == "/api/v2.0/projects" :
w . WriteHeader ( http . StatusCreated )
case r . Method == http . MethodGet && strings . Contains ( r . URL . Path , "/artifacts" ) :
_ = json . NewEncoder ( w ) . Encode ( [ ] map [ string ] any {
{ "digest" : "sha256:aaa" , "push_time" : "2026-04-01T10:00:00Z" } ,
{ "digest" : "sha256:bbb" , "push_time" : "2026-04-01T09:00:00Z" } ,
} )
case r . Method == http . MethodDelete && strings . Contains ( r . URL . Path , "/artifacts/" ) :
w . WriteHeader ( http . StatusAccepted )
default :
http . NotFound ( w , r )
}
} ) )
}
func remoteArtifactNoteForTest ( t * testing . T ) string {
t . Helper ( )
app := & App {
settings : Settings { HarborRegistry : "registry.example" , HarborProject : "metis" } ,
artifactStore : map [ string ] ArtifactSummary {
"titan-15" : { Node : "titan-15" , Ref : "registry.example/metis/titan-15:latest" } ,
} ,
}
return app . remoteArtifactNote ( "titan-15" )
}